also looking at this
feat(android): the location-recording controls in Zig — ratchet 65 → 61
#181
7 files
+568
-22
| @@ -1772,6 +1772,10 @@ class CraftBridge( | ||
| 1772 | 1772 | |
| 1773 | 1773 | @JavascriptInterface |
| 1774 | 1774 | fun startLocationRecording(optionsJson: String): String { |
| 1775 | // Null is "Zig declined". A permission-denied answer is not null — it | |
| 1776 | // is a state object saying so, and returning it here is the answer. | |
| 1777 | CraftNative.startLocationRecording(activity)?.let { return it } | |
| 1778 | ||
| 1775 | 1779 | if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { |
| 1776 | 1780 | return JSONObject().apply { |
| 1777 | 1781 | put("id", JSONObject.NULL) |
| @@ -1792,12 +1796,16 @@ class CraftBridge( | ||
| 1792 | 1796 | |
| 1793 | 1797 | @JavascriptInterface |
| 1794 | 1798 | fun pauseLocationRecording(): String { |
| 1799 | CraftNative.pauseLocationRecording(activity)?.let { return it } | |
| 1800 | ||
| 1795 | 1801 | CraftLocationRecordingStore.setPaused(activity, true) |
| 1796 | 1802 | return CraftLocationRecordingStore.state(activity).toString() |
| 1797 | 1803 | } |
| 1798 | 1804 | |
| 1799 | 1805 | @JavascriptInterface |
| 1800 | 1806 | fun resumeLocationRecording(): String { |
| 1807 | CraftNative.resumeLocationRecording(activity)?.let { return it } | |
| 1808 | ||
| 1801 | 1809 | CraftLocationRecordingStore.setPaused(activity, false) |
| 1802 | 1810 | if (CraftLocationRecordingStore.isActive(activity)) { |
| 1803 | 1811 | ContextCompat.startForegroundService( |
| @@ -1810,6 +1818,8 @@ class CraftBridge( | ||
| 1810 | 1818 | |
| 1811 | 1819 | @JavascriptInterface |
| 1812 | 1820 | fun stopLocationRecording(): String { |
| 1821 | CraftNative.stopLocationRecording(activity)?.let { return it } | |
| 1822 | ||
| 1813 | 1823 | CraftLocationRecordingStore.stop(activity) |
| 1814 | 1824 | val result = CraftLocationRecordingStore.state(activity, includeLocations = true).toString() |
| 1815 | 1825 | activity.stopService(Intent(activity, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_STOP)) |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | package com.craft.runtime |
| 2 | 2 | |
| 3 | 3 | import android.app.Activity |
| 4 | import androidx.core.content.ContextCompat | |
| 5 | import android.content.Intent | |
| 4 | 6 | import android.os.Handler |
| 5 | 7 | import android.os.Looper |
| 6 | 8 | import android.content.SharedPreferences |
| @@ -133,6 +135,10 @@ object CraftNative { | ||
| 133 | 135 | private external fun nativeSaveFile(activity: Activity, data: String, filename: String): Boolean |
| 134 | 136 | private external fun nativeGetLocationRecordingState(activity: Activity): String? |
| 135 | 137 | private external fun nativeReadLocationRecording(activity: Activity): String? |
| 138 | private external fun nativeStartLocationRecording(activity: Activity): String? | |
| 139 | private external fun nativeStopLocationRecording(activity: Activity): String? | |
| 140 | private external fun nativePauseLocationRecording(activity: Activity): String? | |
| 141 | private external fun nativeResumeLocationRecording(activity: Activity): String? | |
| 136 | 142 | |
| 137 | 143 | /** |
| 138 | 144 | * `getDeviceInfo`, or null to use the Kotlin implementation. |
| @@ -674,4 +680,71 @@ object CraftNative { | ||
| 674 | 680 | null |
| 675 | 681 | } |
| 676 | 682 | } |
| 683 | ||
| 684 | /** | |
| 685 | * The four controls. Each returns the store's state as JSON, or null when | |
| 686 | * Zig declined and the Kotlin below should answer instead. | |
| 687 | */ | |
| 688 | fun startLocationRecording(activity: Activity): String? { | |
| 689 | if (!isAvailable) return null | |
| 690 | return try { | |
| 691 | nativeStartLocationRecording(activity) | |
| 692 | } catch (e: UnsatisfiedLinkError) { | |
| 693 | null | |
| 694 | } | |
| 695 | } | |
| 696 | ||
| 697 | fun stopLocationRecording(activity: Activity): String? { | |
| 698 | if (!isAvailable) return null | |
| 699 | return try { | |
| 700 | nativeStopLocationRecording(activity) | |
| 701 | } catch (e: UnsatisfiedLinkError) { | |
| 702 | null | |
| 703 | } | |
| 704 | } | |
| 705 | ||
| 706 | fun pauseLocationRecording(activity: Activity): String? { | |
| 707 | if (!isAvailable) return null | |
| 708 | return try { | |
| 709 | nativePauseLocationRecording(activity) | |
| 710 | } catch (e: UnsatisfiedLinkError) { | |
| 711 | null | |
| 712 | } | |
| 713 | } | |
| 714 | ||
| 715 | fun resumeLocationRecording(activity: Activity): String? { | |
| 716 | if (!isAvailable) return null | |
| 717 | return try { | |
| 718 | nativeResumeLocationRecording(activity) | |
| 719 | } catch (e: UnsatisfiedLinkError) { | |
| 720 | null | |
| 721 | } | |
| 722 | } | |
| 723 | ||
| 724 | /** | |
| 725 | * Start and stop the recording service. | |
| 726 | * | |
| 727 | * Called from Zig, and here rather than there because | |
| 728 | * `LocationRecordingService::class.java` names a class whose package is | |
| 729 | * templated per app — `FindClass` on the native side has no name to look | |
| 730 | * up, and assembling one from `getPackageName()` is the trap the widget | |
| 731 | * broadcast action already documents: an `applicationIdSuffix` moves the | |
| 732 | * runtime package and leaves the class where it was. | |
| 733 | */ | |
| 734 | @JvmStatic | |
| 735 | fun startRecordingService(activity: Activity) { | |
| 736 | ContextCompat.startForegroundService( | |
| 737 | activity, | |
| 738 | Intent(activity, LocationRecordingService::class.java) | |
| 739 | .setAction(LocationRecordingService.ACTION_START) | |
| 740 | ) | |
| 741 | } | |
| 742 | ||
| 743 | @JvmStatic | |
| 744 | fun stopRecordingService(activity: Activity) { | |
| 745 | activity.stopService( | |
| 746 | Intent(activity, LocationRecordingService::class.java) | |
| 747 | .setAction(LocationRecordingService.ACTION_STOP) | |
| 748 | ) | |
| 749 | } | |
| 677 | 750 | } |
| @@ -371,6 +371,26 @@ const natives = [_]jni.JNINativeMethod{ | ||
| 371 | 371 | .signature = "(Landroid/app/Activity;)Ljava/lang/String;", |
| 372 | 372 | .fnPtr = @ptrCast(&nativeReadLocationRecording), |
| 373 | 373 | }, |
| 374 | .{ | |
| 375 | .name = "nativeStartLocationRecording", | |
| 376 | .signature = "(Landroid/app/Activity;)Ljava/lang/String;", | |
| 377 | .fnPtr = @ptrCast(&nativeStartLocationRecording), | |
| 378 | }, | |
| 379 | .{ | |
| 380 | .name = "nativeStopLocationRecording", | |
| 381 | .signature = "(Landroid/app/Activity;)Ljava/lang/String;", | |
| 382 | .fnPtr = @ptrCast(&nativeStopLocationRecording), | |
| 383 | }, | |
| 384 | .{ | |
| 385 | .name = "nativePauseLocationRecording", | |
| 386 | .signature = "(Landroid/app/Activity;)Ljava/lang/String;", | |
| 387 | .fnPtr = @ptrCast(&nativePauseLocationRecording), | |
| 388 | }, | |
| 389 | .{ | |
| 390 | .name = "nativeResumeLocationRecording", | |
| 391 | .signature = "(Landroid/app/Activity;)Ljava/lang/String;", | |
| 392 | .fnPtr = @ptrCast(&nativeResumeLocationRecording), | |
| 393 | }, | |
| 374 | 394 | }; |
| 375 | 395 | |
| 376 | 396 | /// How binding went. A value rather than a log line, so every path is |
| @@ -1539,6 +1559,146 @@ fn nativeReadLocationRecording( | ||
| 1539 | 1559 | return j.newStringUtf8(allocator, locations.json) catch null; |
| 1540 | 1560 | } |
| 1541 | 1561 | |
| 1562 | /// The current state as a Java String, or null if any part of the read | |
| 1563 | /// declines. Shared by every control, since all four answer with one. | |
| 1564 | fn recordingStateString( | |
| 1565 | j: Jni, | |
| 1566 | allocator: std.mem.Allocator, | |
| 1567 | activity: jni.jobject, | |
| 1568 | include_locations: bool, | |
| 1569 | ) jni.jstring { | |
| 1570 | const contents = locationstore.readFile(j, allocator, activity) catch return null; | |
| 1571 | const locations = (locationstore.renderLocations(allocator, contents) catch return null) orelse | |
| 1572 | return null; | |
| 1573 | const state = locationstore.readState(j, allocator, activity, locations.count) catch return null; | |
| 1574 | ||
| 1575 | const json = locationstore.renderStateWith( | |
| 1576 | allocator, | |
| 1577 | state, | |
| 1578 | if (include_locations) locations.json else null, | |
| 1579 | ) catch return null; | |
| 1580 | return j.newStringUtf8(allocator, json) catch null; | |
| 1581 | } | |
| 1582 | ||
| 1583 | /// `nativeStartLocationRecording(activity)`. | |
| 1584 | fn nativeStartLocationRecording( | |
| 1585 | env: jni.JNIEnv, | |
| 1586 | _: jni.jobject, | |
| 1587 | activity: jni.jobject, | |
| 1588 | ) callconv(.c) jni.jstring { | |
| 1589 | const j = Jni.init(env); | |
| 1590 | var arena = std.heap.ArenaAllocator.init(backing); | |
| 1591 | defer arena.deinit(); | |
| 1592 | const allocator = arena.allocator(); | |
| 1593 | ||
| 1594 | const granted = permissions.isGranted(j, activity, permissions.access_fine_location) catch | |
| 1595 | return null; | |
| 1596 | ||
| 1597 | if (!granted) { | |
| 1598 | // A different object from "never recorded": this path builds a fresh | |
| 1599 | // JSONObject with an explicit JSONObject.NULL id, so the key is | |
| 1600 | // present and null rather than absent. | |
| 1601 | const json = locationstore.renderDenied(allocator) catch return null; | |
| 1602 | return j.newStringUtf8(allocator, json) catch null; | |
| 1603 | } | |
| 1604 | ||
| 1605 | const id = locationstore.newRecordingId(j, allocator) catch return null; | |
| 1606 | const started_at = locationstore.nowMillis(j) catch return null; | |
| 1607 | locationstore.startStore(j, allocator, activity, id, started_at) catch return null; | |
| 1608 | ||
| 1609 | // Kotlin names the service class, because its package is templated. | |
| 1610 | startRecordingService(j, activity) catch return null; | |
| 1611 | ||
| 1612 | return recordingStateString(j, allocator, activity, false); | |
| 1613 | } | |
| 1614 | ||
| 1615 | /// `nativeStopLocationRecording(activity)`. | |
| 1616 | /// | |
| 1617 | /// The state is read *before* the service is stopped, as the shim reads it — | |
| 1618 | /// so the array it returns is the recording as it stood at the call. | |
| 1619 | fn nativeStopLocationRecording( | |
| 1620 | env: jni.JNIEnv, | |
| 1621 | _: jni.jobject, | |
| 1622 | activity: jni.jobject, | |
| 1623 | ) callconv(.c) jni.jstring { | |
| 1624 | const j = Jni.init(env); | |
| 1625 | var arena = std.heap.ArenaAllocator.init(backing); | |
| 1626 | defer arena.deinit(); | |
| 1627 | const allocator = arena.allocator(); | |
| 1628 | ||
| 1629 | locationstore.stopStore(j, allocator, activity) catch return null; | |
| 1630 | ||
| 1631 | // Declining after the store write is safe: `stop` is idempotent, so the | |
| 1632 | // shim runs the whole method again and reaches `stopService` itself. | |
| 1633 | const result = recordingStateString(j, allocator, activity, true); | |
| 1634 | if (result == null) return null; | |
| 1635 | ||
| 1636 | stopRecordingService(j, activity) catch return null; | |
| 1637 | return result; | |
| 1638 | } | |
| 1639 | ||
| 1640 | /// `nativePauseLocationRecording(activity)`. | |
| 1641 | fn nativePauseLocationRecording( | |
| 1642 | env: jni.JNIEnv, | |
| 1643 | _: jni.jobject, | |
| 1644 | activity: jni.jobject, | |
| 1645 | ) callconv(.c) jni.jstring { | |
| 1646 | const j = Jni.init(env); | |
| 1647 | var arena = std.heap.ArenaAllocator.init(backing); | |
| 1648 | defer arena.deinit(); | |
| 1649 | const allocator = arena.allocator(); | |
| 1650 | ||
| 1651 | locationstore.setPaused(j, allocator, activity, true) catch return null; | |
| 1652 | return recordingStateString(j, allocator, activity, false); | |
| 1653 | } | |
| 1654 | ||
| 1655 | /// `nativeResumeLocationRecording(activity)`. | |
| 1656 | /// | |
| 1657 | /// Restarts the service only when a recording is active, as the shim does — a | |
| 1658 | /// page resuming something it never started should not raise a foreground | |
| 1659 | /// notification. | |
| 1660 | fn nativeResumeLocationRecording( | |
| 1661 | env: jni.JNIEnv, | |
| 1662 | _: jni.jobject, | |
| 1663 | activity: jni.jobject, | |
| 1664 | ) callconv(.c) jni.jstring { | |
| 1665 | const j = Jni.init(env); | |
| 1666 | var arena = std.heap.ArenaAllocator.init(backing); | |
| 1667 | defer arena.deinit(); | |
| 1668 | const allocator = arena.allocator(); | |
| 1669 | ||
| 1670 | locationstore.setPaused(j, allocator, activity, false) catch return null; | |
| 1671 | ||
| 1672 | const active = locationstore.isActive(j, allocator, activity) catch return null; | |
| 1673 | if (active) startRecordingService(j, activity) catch return null; | |
| 1674 | ||
| 1675 | return recordingStateString(j, allocator, activity, false); | |
| 1676 | } | |
| 1677 | ||
| 1678 | fn startRecordingService(j: Jni, activity: jni.jobject) !void { | |
| 1679 | try j.pushLocalFrame(8); | |
| 1680 | defer _ = j.popLocalFrame(null); | |
| 1681 | ||
| 1682 | const holder = try j.findClass(holder_class); | |
| 1683 | try j.callStaticVoidMethodA( | |
| 1684 | holder, | |
| 1685 | try j.staticMethodId(holder, "startRecordingService", "(Landroid/app/Activity;)V"), | |
| 1686 | &.{.{ .l = activity }}, | |
| 1687 | ); | |
| 1688 | } | |
| 1689 | ||
| 1690 | fn stopRecordingService(j: Jni, activity: jni.jobject) !void { | |
| 1691 | try j.pushLocalFrame(8); | |
| 1692 | defer _ = j.popLocalFrame(null); | |
| 1693 | ||
| 1694 | const holder = try j.findClass(holder_class); | |
| 1695 | try j.callStaticVoidMethodA( | |
| 1696 | holder, | |
| 1697 | try j.staticMethodId(holder, "stopRecordingService", "(Landroid/app/Activity;)V"), | |
| 1698 | &.{.{ .l = activity }}, | |
| 1699 | ); | |
| 1700 | } | |
| 1701 | ||
| 1542 | 1702 | // ============================================================================= |
| 1543 | 1703 | // Tests |
| 1544 | 1704 | // ============================================================================= |
| @@ -1650,7 +1810,7 @@ test "the registered natives name methods the Kotlin actually declares" { | ||
| 1650 | 1810 | // A descriptor is checked by the JVM at registration, so a wrong one fails |
| 1651 | 1811 | // at load rather than at call — but only if the *name* matches something. |
| 1652 | 1812 | // These two strings are the contract with CraftBridge.kt. |
| 1653 | try testing.expectEqual(@as(usize, 39), natives.len); | |
| 1813 | try testing.expectEqual(@as(usize, 43), natives.len); | |
| 1654 | 1814 | try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name)); |
| 1655 | 1815 | |
| 1656 | 1816 | // And the class they bind to is the fixed one, not the templated bridge. |
| @@ -40,6 +40,7 @@ pub const read_calendar = "android.permission.READ_CALENDAR"; | ||
| 40 | 40 | pub const write_calendar = "android.permission.WRITE_CALENDAR"; |
| 41 | 41 | pub const read_contacts = "android.permission.READ_CONTACTS"; |
| 42 | 42 | pub const write_contacts = "android.permission.WRITE_CONTACTS"; |
| 43 | pub const access_fine_location = "android.permission.ACCESS_FINE_LOCATION"; | |
| 43 | 44 | |
| 44 | 45 | /// `activity.checkSelfPermission(permission) == PERMISSION_GRANTED`. |
| 45 | 46 | pub fn isGranted(j: Jni, activity: jobject, permission: [*:0]const u8) !bool { |
| @@ -92,6 +93,7 @@ test "the permission names match Manifest.permission exactly" { | ||
| 92 | 93 | try testing.expectEqualStrings("android.permission.WRITE_CALENDAR", write_calendar); |
| 93 | 94 | try testing.expectEqualStrings("android.permission.READ_CONTACTS", read_contacts); |
| 94 | 95 | try testing.expectEqualStrings("android.permission.WRITE_CONTACTS", write_contacts); |
| 96 | try testing.expectEqualStrings("android.permission.ACCESS_FINE_LOCATION", access_fine_location); | |
| 95 | 97 | } |
| 96 | 98 | |
| 97 | 99 | test "PERMISSION_GRANTED is zero, and PERMISSION_DENIED is not" { |
| @@ -84,6 +84,53 @@ pub fn putString( | ||
| 84 | 84 | ); |
| 85 | 85 | } |
| 86 | 86 | |
| 87 | /// `editor.putBoolean(key, value)`. | |
| 88 | pub fn putBoolean( | |
| 89 | j: Jni, | |
| 90 | allocator: std.mem.Allocator, | |
| 91 | editor: jobject, | |
| 92 | key: []const u8, | |
| 93 | value: bool, | |
| 94 | ) !void { | |
| 95 | try j.pushLocalFrame(4); | |
| 96 | defer _ = j.popLocalFrame(null); | |
| 97 | ||
| 98 | _ = try j.callObjectMethodA( | |
| 99 | editor, | |
| 100 | try j.methodId( | |
| 101 | try j.objectClass(editor), | |
| 102 | "putBoolean", | |
| 103 | "(Ljava/lang/String;Z)Landroid/content/SharedPreferences$Editor;", | |
| 104 | ), | |
| 105 | &.{ | |
| 106 | .{ .l = try j.newStringUtf8(allocator, key) }, | |
| 107 | .{ .z = if (value) jni.JNI_TRUE else jni.JNI_FALSE }, | |
| 108 | }, | |
| 109 | ); | |
| 110 | } | |
| 111 | ||
| 112 | /// `editor.putLong(key, value)`. | |
| 113 | pub fn putLong( | |
| 114 | j: Jni, | |
| 115 | allocator: std.mem.Allocator, | |
| 116 | editor: jobject, | |
| 117 | key: []const u8, | |
| 118 | value: i64, | |
| 119 | ) !void { | |
| 120 | try j.pushLocalFrame(4); | |
| 121 | defer _ = j.popLocalFrame(null); | |
| 122 | ||
| 123 | _ = try j.callObjectMethodA( | |
| 124 | editor, | |
| 125 | try j.methodId( | |
| 126 | try j.objectClass(editor), | |
| 127 | "putLong", | |
| 128 | "(Ljava/lang/String;J)Landroid/content/SharedPreferences$Editor;", | |
| 129 | ), | |
| 130 | &.{ .{ .l = try j.newStringUtf8(allocator, key) }, .{ .j = value } }, | |
| 131 | ); | |
| 132 | } | |
| 133 | ||
| 87 | 134 | /// `editor.remove(key)`. |
| 88 | 135 | pub fn remove(j: Jni, allocator: std.mem.Allocator, editor: jobject, key: []const u8) !void { |
| 89 | 136 | try j.pushLocalFrame(4); |
| @@ -1,12 +1,26 @@ | ||
| 1 | //! `getLocationRecordingState` and `readLocationRecording` on Android. | |
| 1 | //! The location-recording actions on Android: the two reads and the four | |
| 2 | //! controls. | |
| 2 | 3 | //! |
| 3 | //! Both are the *read* side of `CraftLocationRecordingStore`, which a | |
| 4 | //! foreground service writes while a recording runs. Neither starts, stops or | |
| 5 | //! samples anything — those need the service, and the service needs a | |
| 6 | //! `LocationCallback`, which is a Java interface Zig cannot implement. | |
| 4 | //! `CraftLocationRecordingStore` is a preferences file and a JSONL file, and | |
| 5 | //! everything here is one or both of those — plus, for two of the controls, a | |
| 6 | //! foreground service that has to be started or stopped. | |
| 7 | 7 | //! |
| 8 | //! So this migrates the half that is a file and a preferences read, and leaves | |
| 9 | //! the half that is a service alone. | |
| 8 | //! What stays with the shim is the *sampling*: the service holds a | |
| 9 | //! `LocationCallback`, a Java interface Zig cannot implement. So Zig owns the | |
| 10 | //! record of a recording and Kotlin owns the thing that fills it. | |
| 11 | //! | |
| 12 | //! ## The service Intent is built by Kotlin | |
| 13 | //! | |
| 14 | //! `Intent(activity, LocationRecordingService::class.java)` names a class | |
| 15 | //! whose package is `` — templated per app, so `FindClass` | |
| 16 | //! here has no name to look up. Zig could assemble one from | |
| 17 | //! `getPackageName()`, and that is the same trap the widget broadcast action | |
| 18 | //! sits in: an `applicationIdSuffix` moves the runtime package and leaves the | |
| 19 | //! class where it was. | |
| 20 | //! | |
| 21 | //! So `CraftNative` starts and stops the service with its own class | |
| 22 | //! reference, the way it passes its own broadcast constant across. One side | |
| 23 | //! decides. | |
| 10 | 24 | //! |
| 11 | 25 | //! ## Both return a String rather than answering through the channel |
| 12 | 26 | //! |
| @@ -45,6 +59,10 @@ const jobject = jni.jobject; | ||
| 45 | 59 | pub const A = struct { |
| 46 | 60 | pub const get_location_recording_state = "getLocationRecordingState"; |
| 47 | 61 | pub const read_location_recording = "readLocationRecording"; |
| 62 | pub const start_location_recording = "startLocationRecording"; | |
| 63 | pub const stop_location_recording = "stopLocationRecording"; | |
| 64 | pub const pause_location_recording = "pauseLocationRecording"; | |
| 65 | pub const resume_location_recording = "resumeLocationRecording"; | |
| 48 | 66 | }; |
| 49 | 67 | |
| 50 | 68 | /// `CraftLocationRecordingStore.PREFS` and `FILE`. |
| @@ -71,6 +89,19 @@ pub const State = struct { | ||
| 71 | 89 | /// function with a test rather than a format string: a missing `id` has no |
| 72 | 90 | /// key at all, and a missing `startedAt` is present and null. |
| 73 | 91 | pub fn renderState(allocator: std.mem.Allocator, state: State) ![]u8 { |
| 92 | return renderStateWith(allocator, state, null); | |
| 93 | } | |
| 94 | ||
| 95 | /// The same, with `includeLocations = true`. | |
| 96 | /// | |
| 97 | /// `stopLocationRecording` is the only caller that asks for it, and it asks | |
| 98 | /// *before* stopping the service — so the array it returns is the recording | |
| 99 | /// as it stood at the moment of the call. | |
| 100 | pub fn renderStateWith( | |
| 101 | allocator: std.mem.Allocator, | |
| 102 | state: State, | |
| 103 | locations_json: ?[]const u8, | |
| 104 | ) ![]u8 { | |
| 74 | 105 | var out: std.ArrayListUnmanaged(u8) = .empty; |
| 75 | 106 | errdefer out.deinit(allocator); |
| 76 | 107 | |
| @@ -92,10 +123,30 @@ pub fn renderState(allocator: std.mem.Allocator, state: State) ![]u8 { | ||
| 92 | 123 | try out.appendSlice(allocator, "null"); |
| 93 | 124 | } |
| 94 | 125 | |
| 95 | try out.print(allocator, ",\"sampleCount\":{d}}}", .{state.sample_count}); | |
| 126 | try out.print(allocator, ",\"sampleCount\":{d}", .{state.sample_count}); | |
| 127 | if (locations_json) |locations| { | |
| 128 | try out.appendSlice(allocator, ",\"locations\":"); | |
| 129 | try out.appendSlice(allocator, locations); | |
| 130 | } | |
| 131 | try out.append(allocator, '}'); | |
| 96 | 132 | return out.toOwnedSlice(allocator); |
| 97 | 133 | } |
| 98 | 134 | |
| 135 | /// What `startLocationRecording` returns when the permission is not granted. | |
| 136 | /// | |
| 137 | /// Not `renderState` with everything empty, and the difference is the point: | |
| 138 | /// this path builds a fresh `JSONObject` with `put("id", JSONObject.NULL)`, an | |
| 139 | /// **explicit** null whose key stays — while `state()` uses | |
| 140 | /// `put("id", getString("id", null))`, a Kotlin null that *removes* the key. | |
| 141 | /// | |
| 142 | /// So "permission denied" and "never recorded" are two different objects, and | |
| 143 | /// a page can tell them apart only by whether `id` is present at all. | |
| 144 | pub fn renderDenied(allocator: std.mem.Allocator) ![]u8 { | |
| 145 | return allocator.dupe(u8, | |
| 146 | \\{"id":null,"active":false,"paused":false,"startedAt":null,"sampleCount":0} | |
| 147 | ); | |
| 148 | } | |
| 149 | ||
| 99 | 150 | /// The array and how many samples are in it. |
| 100 | 151 | /// |
| 101 | 152 | /// One function rather than two, so `sampleCount` and the array a page reads |
| @@ -145,17 +196,8 @@ pub fn readFile(j: Jni, allocator: std.mem.Allocator, activity: jobject) ![]u8 { | ||
| 145 | 196 | try j.pushLocalFrame(16); |
| 146 | 197 | defer _ = j.popLocalFrame(null); |
| 147 | 198 | |
| 148 | const files_dir = try j.callObjectMethod( | |
| 149 | activity, | |
| 150 | try j.methodId(try j.objectClass(activity), "getFilesDir", "()Ljava/io/File;"), | |
| 151 | ); | |
| 152 | ||
| 153 | const file_cls = try j.findClass("java/io/File"); | |
| 154 | const file = try j.newObjectA( | |
| 155 | file_cls, | |
| 156 | try j.methodId(file_cls, "<init>", "(Ljava/io/File;Ljava/lang/String;)V"), | |
| 157 | &.{ .{ .l = files_dir }, .{ .l = try j.newStringUtf(file_name) } }, | |
| 158 | ); | |
| 199 | const file = try recordingFile(j, activity); | |
| 200 | const file_cls = try j.objectClass(file); | |
| 159 | 201 | |
| 160 | 202 | // `if (!source.exists()) return result` — an empty array rather than an |
| 161 | 203 | // error, because a recording that never started has no file. |
| @@ -215,6 +257,132 @@ pub fn readState( | ||
| 215 | 257 | }; |
| 216 | 258 | } |
| 217 | 259 | |
| 260 | /// `CraftLocationRecordingStore.start(context, id, startedAt)`. | |
| 261 | /// | |
| 262 | /// Truncates the sample file first, as the store does — a new recording does | |
| 263 | /// not inherit the last one's points. Opening a `FileOutputStream` without the | |
| 264 | /// append flag is `writeText("")`: it creates or truncates and writes nothing. | |
| 265 | pub fn startStore( | |
| 266 | j: Jni, | |
| 267 | allocator: std.mem.Allocator, | |
| 268 | activity: jobject, | |
| 269 | id: []const u8, | |
| 270 | started_at: i64, | |
| 271 | ) !void { | |
| 272 | try j.pushLocalFrame(24); | |
| 273 | defer _ = j.popLocalFrame(null); | |
| 274 | ||
| 275 | try truncateFile(j, activity); | |
| 276 | ||
| 277 | const prefs = try prefs_api.open(j, allocator, activity, prefs_name); | |
| 278 | const editor = try prefs_api.edit(j, prefs); | |
| 279 | try prefs_api.putString(j, allocator, editor, "id", id); | |
| 280 | try prefs_api.putBoolean(j, allocator, editor, "active", true); | |
| 281 | try prefs_api.putBoolean(j, allocator, editor, "paused", false); | |
| 282 | try prefs_api.putLong(j, allocator, editor, "startedAt", started_at); | |
| 283 | try prefs_api.apply(j, editor); | |
| 284 | } | |
| 285 | ||
| 286 | /// `CraftLocationRecordingStore.setPaused(context, paused)`. | |
| 287 | pub fn setPaused(j: Jni, allocator: std.mem.Allocator, activity: jobject, paused: bool) !void { | |
| 288 | try j.pushLocalFrame(16); | |
| 289 | defer _ = j.popLocalFrame(null); | |
| 290 | ||
| 291 | const prefs = try prefs_api.open(j, allocator, activity, prefs_name); | |
| 292 | const editor = try prefs_api.edit(j, prefs); | |
| 293 | try prefs_api.putBoolean(j, allocator, editor, "paused", paused); | |
| 294 | try prefs_api.apply(j, editor); | |
| 295 | } | |
| 296 | ||
| 297 | /// `CraftLocationRecordingStore.stop(context)`. | |
| 298 | /// | |
| 299 | /// Clears `paused` as well as `active`, so a recording stopped while paused | |
| 300 | /// does not come back paused. The sample file is left alone — `stop` is what | |
| 301 | /// makes the points readable, not what discards them. | |
| 302 | pub fn stopStore(j: Jni, allocator: std.mem.Allocator, activity: jobject) !void { | |
| 303 | try j.pushLocalFrame(16); | |
| 304 | defer _ = j.popLocalFrame(null); | |
| 305 | ||
| 306 | const prefs = try prefs_api.open(j, allocator, activity, prefs_name); | |
| 307 | const editor = try prefs_api.edit(j, prefs); | |
| 308 | try prefs_api.putBoolean(j, allocator, editor, "active", false); | |
| 309 | try prefs_api.putBoolean(j, allocator, editor, "paused", false); | |
| 310 | try prefs_api.apply(j, editor); | |
| 311 | } | |
| 312 | ||
| 313 | /// `UUID.randomUUID().toString()`. | |
| 314 | /// | |
| 315 | /// Through Java rather than from a Zig random source: the id is the shim's to | |
| 316 | /// define, and `UUID.toString` is a specific hyphenated format a page may well | |
| 317 | /// be matching on. | |
| 318 | pub fn newRecordingId(j: Jni, allocator: std.mem.Allocator) ![]u8 { | |
| 319 | try j.pushLocalFrame(8); | |
| 320 | defer _ = j.popLocalFrame(null); | |
| 321 | ||
| 322 | const uuid_cls = try j.findClass("java/util/UUID"); | |
| 323 | const uuid = try j.callStaticObjectMethodA( | |
| 324 | uuid_cls, | |
| 325 | try j.staticMethodId(uuid_cls, "randomUUID", "()Ljava/util/UUID;"), | |
| 326 | &.{}, | |
| 327 | ); | |
| 328 | const text = try j.callObjectMethod( | |
| 329 | uuid, | |
| 330 | try j.methodId(uuid_cls, "toString", "()Ljava/lang/String;"), | |
| 331 | ); | |
| 332 | return j.stringToUtf8(allocator, text); | |
| 333 | } | |
| 334 | ||
| 335 | /// `System.currentTimeMillis()`. | |
| 336 | pub fn nowMillis(j: Jni) !i64 { | |
| 337 | try j.pushLocalFrame(4); | |
| 338 | defer _ = j.popLocalFrame(null); | |
| 339 | ||
| 340 | const system_cls = try j.findClass("java/lang/System"); | |
| 341 | return j.callStaticLongMethodA( | |
| 342 | system_cls, | |
| 343 | try j.staticMethodId(system_cls, "currentTimeMillis", "()J"), | |
| 344 | &.{}, | |
| 345 | ); | |
| 346 | } | |
| 347 | ||
| 348 | /// `prefs.getBoolean("active", false)` — what `resumeLocationRecording` asks | |
| 349 | /// before restarting the service. | |
| 350 | pub fn isActive(j: Jni, allocator: std.mem.Allocator, activity: jobject) !bool { | |
| 351 | try j.pushLocalFrame(8); | |
| 352 | defer _ = j.popLocalFrame(null); | |
| 353 | ||
| 354 | const prefs = try prefs_api.open(j, allocator, activity, prefs_name); | |
| 355 | return prefs_api.getBoolean(j, allocator, prefs, "active", false); | |
| 356 | } | |
| 357 | ||
| 358 | fn recordingFile(j: Jni, activity: jobject) !jobject { | |
| 359 | const files_dir = try j.callObjectMethod( | |
| 360 | activity, | |
| 361 | try j.methodId(try j.objectClass(activity), "getFilesDir", "()Ljava/io/File;"), | |
| 362 | ); | |
| 363 | ||
| 364 | const file_cls = try j.findClass("java/io/File"); | |
| 365 | return j.newObjectA( | |
| 366 | file_cls, | |
| 367 | try j.methodId(file_cls, "<init>", "(Ljava/io/File;Ljava/lang/String;)V"), | |
| 368 | &.{ .{ .l = files_dir }, .{ .l = try j.newStringUtf(file_name) } }, | |
| 369 | ); | |
| 370 | } | |
| 371 | ||
| 372 | fn truncateFile(j: Jni, activity: jobject) !void { | |
| 373 | try j.pushLocalFrame(8); | |
| 374 | defer _ = j.popLocalFrame(null); | |
| 375 | ||
| 376 | const file = try recordingFile(j, activity); | |
| 377 | const stream_cls = try j.findClass("java/io/FileOutputStream"); | |
| 378 | const stream = try j.newObjectA( | |
| 379 | stream_cls, | |
| 380 | try j.methodId(stream_cls, "<init>", "(Ljava/io/File;)V"), | |
| 381 | &.{.{ .l = file }}, | |
| 382 | ); | |
| 383 | try j.callVoidMethodA(stream, try j.methodId(stream_cls, "close", "()V"), &.{}); | |
| 384 | } | |
| 385 | ||
| 218 | 386 | // ============================================================================= |
| 219 | 387 | // Tests |
| 220 | 388 | // ============================================================================= |
| @@ -353,3 +521,88 @@ test "the actions match the shim exactly" { | ||
| 353 | 521 | try testing.expectEqualStrings("getLocationRecordingState", A.get_location_recording_state); |
| 354 | 522 | try testing.expectEqualStrings("readLocationRecording", A.read_location_recording); |
| 355 | 523 | } |
| 524 | ||
| 525 | // --- the controls ---------------------------------------------------------- | |
| 526 | ||
| 527 | test "permission denied is a different object from never having recorded" { | |
| 528 | // The one place these two shapes differ, and the reason `renderDenied` is | |
| 529 | // its own function: the denied path builds a fresh JSONObject with | |
| 530 | // `put("id", JSONObject.NULL)` — an explicit null whose key stays — while | |
| 531 | // `state()` uses `put("id", getString("id", null))`, a Kotlin null that | |
| 532 | // removes the key. A page can only tell them apart by whether `id` is | |
| 533 | // there at all. | |
| 534 | const denied = try renderDenied(testing.allocator); | |
| 535 | defer testing.allocator.free(denied); | |
| 536 | ||
| 537 | const never = try renderState(testing.allocator, .{ | |
| 538 | .id = null, | |
| 539 | .active = false, | |
| 540 | .paused = false, | |
| 541 | .started_at = null, | |
| 542 | .sample_count = 0, | |
| 543 | }); | |
| 544 | defer testing.allocator.free(never); | |
| 545 | ||
| 546 | try testing.expectEqualStrings( | |
| 547 | \\{"id":null,"active":false,"paused":false,"startedAt":null,"sampleCount":0} | |
| 548 | , denied); | |
| 549 | try testing.expectEqualStrings( | |
| 550 | \\{"active":false,"paused":false,"startedAt":null,"sampleCount":0} | |
| 551 | , never); | |
| 552 | ||
| 553 | // Everything else about them agrees, which is what makes the one | |
| 554 | // difference easy to lose. | |
| 555 | try testing.expect(!std.mem.eql(u8, denied, never)); | |
| 556 | ||
| 557 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, denied, .{}); | |
| 558 | defer parsed.deinit(); | |
| 559 | try testing.expect(parsed.value.object.get("id").? == .null); | |
| 560 | } | |
| 561 | ||
| 562 | test "only stopLocationRecording asks for the locations" { | |
| 563 | // `state(context, includeLocations = false)` everywhere else, so the key | |
| 564 | // is absent rather than empty — a page reading `locations` on a pause | |
| 565 | // reply gets undefined, not []. | |
| 566 | const without = try renderStateWith(testing.allocator, .{ | |
| 567 | .id = "run-7", | |
| 568 | .active = true, | |
| 569 | .paused = false, | |
| 570 | .started_at = 1, | |
| 571 | .sample_count = 2, | |
| 572 | }, null); | |
| 573 | defer testing.allocator.free(without); | |
| 574 | try testing.expect(std.mem.indexOf(u8, without, "locations") == null); | |
| 575 | ||
| 576 | const with = try renderStateWith(testing.allocator, .{ | |
| 577 | .id = "run-7", | |
| 578 | .active = false, | |
| 579 | .paused = false, | |
| 580 | .started_at = 1, | |
| 581 | .sample_count = 2, | |
| 582 | }, "[{\"a\":1},{\"b\":2}]"); | |
| 583 | defer testing.allocator.free(with); | |
| 584 | ||
| 585 | try testing.expectEqualStrings( | |
| 586 | \\{"id":"run-7","active":false,"paused":false,"startedAt":1,"sampleCount":2,"locations":[{"a":1},{"b":2}]} | |
| 587 | , with); | |
| 588 | ||
| 589 | // `locations` comes last, after `sampleCount`, because the store's | |
| 590 | // `if (includeLocations) put(...)` runs after every other put. | |
| 591 | var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, with, .{}); | |
| 592 | defer parsed.deinit(); | |
| 593 | try testing.expectEqual( | |
| 594 | @as(i64, 2), | |
| 595 | parsed.value.object.get("sampleCount").?.integer, | |
| 596 | ); | |
| 597 | try testing.expectEqual( | |
| 598 | @as(usize, 2), | |
| 599 | parsed.value.object.get("locations").?.array.items.len, | |
| 600 | ); | |
| 601 | } | |
| 602 | ||
| 603 | test "the four controls name themselves as the shim does" { | |
| 604 | try testing.expectEqualStrings("startLocationRecording", A.start_location_recording); | |
| 605 | try testing.expectEqualStrings("stopLocationRecording", A.stop_location_recording); | |
| 606 | try testing.expectEqualStrings("pauseLocationRecording", A.pause_location_recording); | |
| 607 | try testing.expectEqualStrings("resumeLocationRecording", A.resume_location_recording); | |
| 608 | } | |
| @@ -86,8 +86,9 @@ const zig_sources = [_][]const u8{ | ||
| 86 | 86 | /// setKeepAwake, whose Kotlin field turns out to be written and never read; |
| 87 | 87 | /// 67 with downloadFile and saveFile, neither of which needed the main thread |
| 88 | 88 | /// at all; 65 with the recording store's two reads, whose writers stay with |
| 89 | /// the service that owns them. | |
| 90 | const max_not_yet_migrated: usize = 65; | |
| 89 | /// the service that owns them; 61 with the four controls, once Kotlin agreed | |
| 90 | /// to name its own service class. | |
| 91 | const max_not_yet_migrated: usize = 61; | |
| 91 | 92 | |
| 92 | 93 | /// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge. |
| 93 | 94 | /// |