ReviewOS

also looking at this

craft-native/craft

feat(android): downloadFile and saveFile in Zig — ratchet 69 → 67

#176
Merged glennmichael123 wants to merge feat/android-files into main
6 files +477 -3
packages/android/templates/CraftBridge.kt.templatemodified+8-0
Changes to packages/android/templates/CraftBridge.kt.template
@@ -2663,6 +2663,10 @@ class CraftBridge(
26632663
26642664 @JavascriptInterface
26652665 fun downloadFile(url: String, filename: String) {
2666 // Zig answers through the reply channel, so returning here is what
2667 // stops the promise being settled twice.
2668 if (CraftNative.downloadFile(activity, url, filename)) return
2669
26662670 try {
26672671 val request = DownloadManager.Request(Uri.parse(url)).apply {
26682672 setTitle(filename)
@@ -2692,6 +2696,10 @@ class CraftBridge(
26922696
26932697 @JavascriptInterface
26942698 fun saveFile(data: String, filename: String) {
2699 // Zig answers through the reply channel, so returning here is what
2700 // stops the promise being settled twice.
2701 if (CraftNative.saveFile(activity, data, filename)) return
2702
26952703 try {
26962704 val documentsDir = activity.getExternalFilesDir(android.os.Environment.DIRECTORY_DOCUMENTS)
26972705 val file = File(documentsDir, filename)
packages/android/templates/CraftNative.kt.templatemodified+21-0
Changes to packages/android/templates/CraftNative.kt.template
@@ -129,6 +129,8 @@ object CraftNative {
129129 private external fun nativeLockOrientation(activity: Activity, orientation: String): Boolean
130130 private external fun nativeUnlockOrientation(activity: Activity): Boolean
131131 private external fun nativeSetKeepAwake(activity: Activity, enabled: Boolean): Boolean
132 private external fun nativeDownloadFile(activity: Activity, url: String, filename: String): Boolean
133 private external fun nativeSaveFile(activity: Activity, data: String, filename: String): Boolean
132134
133135 /**
134136 * `getDeviceInfo`, or null to use the Kotlin implementation.
@@ -629,4 +631,23 @@ object CraftNative {
629631 false
630632 }
631633 }
634
635 /** Both answer through the reply channel; true means Zig took the action. */
636 fun downloadFile(activity: Activity, url: String, filename: String): Boolean {
637 if (!isAvailable) return false
638 return try {
639 nativeDownloadFile(activity, url, filename)
640 } catch (e: UnsatisfiedLinkError) {
641 false
642 }
643 }
644
645 fun saveFile(activity: Activity, data: String, filename: String): Boolean {
646 if (!isAvailable) return false
647 return try {
648 nativeSaveFile(activity, data, filename)
649 } catch (e: UnsatisfiedLinkError) {
650 false
651 }
652 }
632653}
packages/zig/build.zigmodified+3-0
Changes to packages/zig/build.zig
@@ -476,6 +476,9 @@ pub fn build(b: *std.Build) void {
476476 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_screen.zig", .{
477477 .root_source_file = b.path("src/bridge_android_screen.zig"),
478478 });
479 android_conformance_tests.root_module.addAnonymousImport("src/bridge_android_files.zig", .{
480 .root_source_file = b.path("src/bridge_android_files.zig"),
481 });
479482 const run_android_conformance_tests = b.addRunArtifact(android_conformance_tests);
480483
481484 // The page surface, across both bridges. Not folded into the iOS
packages/zig/src/android_dispatch.zigmodified+72-1
Changes to packages/zig/src/android_dispatch.zig
@@ -53,6 +53,7 @@ const contacts = ;
5353const widgets = ;
5454const shortcuts = ;
5555const screen = ;
56const files = ;
5657const main_thread = ;
5758
5859const Jni = jni.Jni;
@@ -347,6 +348,16 @@ const natives = [_]jni.JNINativeMethod{
347348 .signature = "(Landroid/app/Activity;Z)Z",
348349 .fnPtr = @ptrCast(&nativeSetKeepAwake),
349350 },
351 .{
352 .name = "nativeDownloadFile",
353 .signature = "(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;)Z",
354 .fnPtr = @ptrCast(&nativeDownloadFile),
355 },
356 .{
357 .name = "nativeSaveFile",
358 .signature = "(Landroid/app/Activity;Ljava/lang/String;Ljava/lang/String;)Z",
359 .fnPtr = @ptrCast(&nativeSaveFile),
360 },
350361};
351362
352363/// How binding went. A value rather than a log line, so every path is
@@ -1401,6 +1412,66 @@ fn nativeSetKeepAwake(
14011412 return jni.JNI_TRUE;
14021413}
14031414
1415/// `nativeDownloadFile(activity, url, filename)`.
1416///
1417/// Resolves with the download id as a JSON *string*, because the shim
1418/// interpolated its `Long` into a quoted JavaScript string and the page has
1419/// always received text.
1420fn nativeDownloadFile(
1421 env: jni.JNIEnv,
1422 _: jni.jobject,
1423 activity: jni.jobject,
1424 url: jni.jstring,
1425 filename: jni.jstring,
1426) callconv(.c) jni.jboolean {
1427 const j = Jni.init(env);
1428 var arena = std.heap.ArenaAllocator.init(backing);
1429 defer arena.deinit();
1430 const allocator = arena.allocator();
1431
1432 const url_text = j.stringToUtf8(allocator, url) catch return jni.JNI_FALSE;
1433 const name_text = j.stringToUtf8(allocator, filename) catch return jni.JNI_FALSE;
1434
1435 const id = files.download(j, allocator, activity, url_text, name_text) catch |err| {
1436 calendar.rejectOn(allocator, files.download_reject_global, @errorName(err)) catch {};
1437 return jni.JNI_TRUE;
1438 };
1439
1440 var buf: [24]u8 = undefined;
1441 const digits = std.fmt.bufPrint(&buf, "{d}", .{id}) catch return jni.JNI_FALSE;
1442 const payload = files.jsonString(allocator, digits) catch return jni.JNI_FALSE;
1443 events.settle(allocator, files.download_resolve_global, payload) catch return jni.JNI_FALSE;
1444 return jni.JNI_TRUE;
1445}
1446
1447/// `nativeSaveFile(activity, data, filename)`.
1448fn nativeSaveFile(
1449 env: jni.JNIEnv,
1450 _: jni.jobject,
1451 activity: jni.jobject,
1452 data: jni.jstring,
1453 filename: jni.jstring,
1454) callconv(.c) jni.jboolean {
1455 const j = Jni.init(env);
1456 var arena = std.heap.ArenaAllocator.init(backing);
1457 defer arena.deinit();
1458 const allocator = arena.allocator();
1459
1460 const data_text = j.stringToUtf8(allocator, data) catch return jni.JNI_FALSE;
1461 const name_text = j.stringToUtf8(allocator, filename) catch return jni.JNI_FALSE;
1462
1463 // The plan can be `nothing`, and the shim still resolves with the path.
1464 // Reproduced rather than corrected — see #175.
1465 const path = files.save(j, allocator, activity, name_text, files.planFor(data_text)) catch |err| {
1466 calendar.rejectOn(allocator, files.save_reject_global, @errorName(err)) catch {};
1467 return jni.JNI_TRUE;
1468 };
1469
1470 const payload = files.jsonString(allocator, path) catch return jni.JNI_FALSE;
1471 events.settle(allocator, files.save_resolve_global, payload) catch return jni.JNI_FALSE;
1472 return jni.JNI_TRUE;
1473}
1474
14041475// =============================================================================
14051476// Tests
14061477// =============================================================================
@@ -1512,7 +1583,7 @@ test "the registered natives name methods the Kotlin actually declares" {
15121583 // A descriptor is checked by the JVM at registration, so a wrong one fails
15131584 // at load rather than at call — but only if the *name* matches something.
15141585 // These two strings are the contract with CraftBridge.kt.
1515 try testing.expectEqual(@as(usize, 35), natives.len);
1586 try testing.expectEqual(@as(usize, 37), natives.len);
15161587 try testing.expectEqualStrings("nativeGetDeviceInfo", std.mem.span(natives[0].name));
15171588
15181589 // And the class they bind to is the fixed one, not the templated bridge.
packages/zig/src/bridge_android_files.zigadded+368-0
Changes to packages/zig/src/bridge_android_files.zig
@@ -0,0 +1,368 @@
1//! `downloadFile` and `saveFile` on Android.
2//!
3//! Neither needs the main thread, a listener, or an Activity result — the
4//! download is queued with the system's own manager and the save is ordinary
5//! file I/O — so both are served whole rather than in halves.
6//!
7//! ## saveFile can report a path it never wrote
8//!
9//! The shim's data-URL branch is:
10//!
11//! ```kotlin
12//! if (data.startsWith("data:")) {
13//! val parts = data.split(",")
14//! if (parts.size == 2) { ...decode and write... }
15//! } else {
16//! file.writeText(data)
17//! }
18//! ...resolve(file.absolutePath)
19//! ```
20//!
21//! `split(",")` splits on *every* comma, so a data URL whose payload contains
22//! one — base64 does not produce commas, but `data:text/plain,a,b` is a legal
23//! data URL — yields three parts, the `if` is skipped, **nothing is written**,
24//! and the resolve still hands the page a path. The file may not exist at all.
25//!
26//! Reproduced rather than corrected, because the reply is what a page acts on
27//! and changing it is a behaviour change. Recorded as `Plan.nothing` so the
28//! case has a name instead of being an absent `else`. See #175.
29//!
30//! ## The decoding is Java's
31//!
32//! `android.util.Base64.decode` rather than a decoder here, for the reason
33//! `bridge_android_notifications` hashes ids in Java: what matters is not
34//! decoding base64 but decoding it the way the shim does, including which
35//! inputs it throws on. A Zig decoder would be a second implementation whose
36//! disagreements only ever show up on a device.
37
38const std = ;
39const jni = ;
40const bridge_error = ;
41
42const Jni = jni.Jni;
43const jobject = jni.jobject;
44
45pub const A = struct {
46 pub const download_file = "downloadFile";
47 pub const save_file = "saveFile";
48};
49
50pub const download_resolve_global = "_craftDownloadResolve";
51pub const download_reject_global = "_craftDownloadReject";
52pub const save_resolve_global = "_craftSaveResolve";
53pub const save_reject_global = "_craftSaveReject";
54
55/// What the shim's notification says while a download runs.
56const download_description = "Downloading...";
57
58/// What `saveFile` will do with the data it was given.
59pub const Plan = union(enum) {
60 /// `file.writeText(data)` — anything not starting with `data:`.
61 text: []const u8,
62 /// `Base64.decode(parts[1])` — a data URL with exactly one comma.
63 base64: []const u8,
64 /// A data URL with any other number of commas. The shim writes nothing
65 /// and resolves with the path anyway.
66 nothing,
67};
68
69/// The shim's branch, comma counting included.
70pub fn planFor(data: []const u8) Plan {
71 if (!std.mem.startsWith(u8, data, "data:")) return .{ .text = data };
72
73 // `split(",")` gives `count + 1` parts, so "exactly one comma" is the
74 // whole of `parts.size == 2`.
75 const first = std.mem.indexOfScalar(u8, data, ',') orelse return .nothing;
76 if (std.mem.indexOfScalarPos(u8, data, first + 1, ',') != null) return .nothing;
77
78 return .{ .base64 = data[first + 1 ..] };
79}
80
81/// `downloadManager.enqueue(request)`, and the id it returns.
82pub fn download(
83 j: Jni,
84 allocator: std.mem.Allocator,
85 activity: jobject,
86 url: []const u8,
87 filename: []const u8,
88) !i64 {
89 try j.pushLocalFrame(32);
90 defer _ = j.popLocalFrame(null);
91
92 const uri_cls = try j.findClass("android/net/Uri");
93 const uri = try j.callStaticObjectMethodA(
94 uri_cls,
95 try j.staticMethodId(uri_cls, "parse", "(Ljava/lang/String;)Landroid/net/Uri;"),
96 &.{.{ .l = try j.newStringUtf8(allocator, url) }},
97 );
98
99 const request_cls = try j.findClass("android/app/DownloadManager$Request");
100 const request = try j.newObjectA(
101 request_cls,
102 try j.methodId(request_cls, "<init>", "(Landroid/net/Uri;)V"),
103 &.{.{ .l = uri }},
104 );
105
106 _ = try j.callObjectMethodA(
107 request,
108 try j.methodId(
109 request_cls,
110 "setTitle",
111 "(Ljava/lang/CharSequence;)Landroid/app/DownloadManager$Request;",
112 ),
113 &.{.{ .l = try j.newStringUtf8(allocator, filename) }},
114 );
115
116 _ = try j.callObjectMethodA(
117 request,
118 try j.methodId(
119 request_cls,
120 "setDescription",
121 "(Ljava/lang/CharSequence;)Landroid/app/DownloadManager$Request;",
122 ),
123 &.{.{ .l = try j.newStringUtf(download_description) }},
124 );
125
126 // Read rather than written down: a wrong visibility does not throw, it
127 // just silently stops the user seeing the download.
128 const visibility = try j.staticIntField(
129 request_cls,
130 try j.staticFieldId(request_cls, "VISIBILITY_VISIBLE_NOTIFY_COMPLETED", "I"),
131 );
132 _ = try j.callObjectMethodA(
133 request,
134 try j.methodId(request_cls, "setNotificationVisibility", "(I)Landroid/app/DownloadManager$Request;"),
135 &.{.{ .i = visibility }},
136 );
137
138 const environment_cls = try j.findClass("android/os/Environment");
139 const downloads_dir = try j.staticObjectField(
140 environment_cls,
141 try j.staticFieldId(environment_cls, "DIRECTORY_DOWNLOADS", "Ljava/lang/String;"),
142 );
143 _ = try j.callObjectMethodA(
144 request,
145 try j.methodId(
146 request_cls,
147 "setDestinationInExternalPublicDir",
148 "(Ljava/lang/String;Ljava/lang/String;)Landroid/app/DownloadManager$Request;",
149 ),
150 &.{ .{ .l = downloads_dir }, .{ .l = try j.newStringUtf8(allocator, filename) } },
151 );
152
153 const context_cls = try j.findClass("android/content/Context");
154 const service_name = try j.staticObjectField(
155 context_cls,
156 try j.staticFieldId(context_cls, "DOWNLOAD_SERVICE", "Ljava/lang/String;"),
157 );
158 const manager = try j.callObjectMethodA(
159 activity,
160 try j.methodId(
161 try j.objectClass(activity),
162 "getSystemService",
163 "(Ljava/lang/String;)Ljava/lang/Object;",
164 ),
165 &.{.{ .l = service_name }},
166 );
167
168 return j.callLongMethodA(
169 manager,
170 try j.methodId(
171 try j.objectClass(manager),
172 "enqueue",
173 "(Landroid/app/DownloadManager$Request;)J",
174 ),
175 &.{.{ .l = request }},
176 );
177}
178
179/// Write the file and return its absolute path.
180///
181/// The path is produced whether or not anything was written, because that is
182/// what the shim resolves with — see the note at the top of this file.
183pub fn save(
184 j: Jni,
185 allocator: std.mem.Allocator,
186 activity: jobject,
187 filename: []const u8,
188 plan: Plan,
189) ![]u8 {
190 try j.pushLocalFrame(32);
191 defer _ = j.popLocalFrame(null);
192
193 const environment_cls = try j.findClass("android/os/Environment");
194 const documents_dir = try j.staticObjectField(
195 environment_cls,
196 try j.staticFieldId(environment_cls, "DIRECTORY_DOCUMENTS", "Ljava/lang/String;"),
197 );
198 const parent = try j.callObjectMethodA(
199 activity,
200 try j.methodId(
201 try j.objectClass(activity),
202 "getExternalFilesDir",
203 "(Ljava/lang/String;)Ljava/io/File;",
204 ),
205 &.{.{ .l = documents_dir }},
206 );
207
208 const file_cls = try j.findClass("java/io/File");
209 const file = try j.newObjectA(
210 file_cls,
211 try j.methodId(file_cls, "<init>", "(Ljava/io/File;Ljava/lang/String;)V"),
212 &.{ .{ .l = parent }, .{ .l = try j.newStringUtf8(allocator, filename) } },
213 );
214
215 switch (plan) {
216 .nothing => {},
217 .text => |text| try writeBytes(j, file, try utf8Bytes(j, allocator, text)),
218 .base64 => |encoded| try writeBytes(j, file, try decodeBase64(j, allocator, encoded)),
219 }
220
221 const path = try j.callObjectMethod(
222 file,
223 try j.methodId(file_cls, "getAbsolutePath", "()Ljava/lang/String;"),
224 );
225 return j.stringToUtf8(allocator, path);
226}
227
228/// `Base64.decode(encoded, Base64.DEFAULT)`.
229fn decodeBase64(j: Jni, allocator: std.mem.Allocator, encoded: []const u8) !jobject {
230 const base64_cls = try j.findClass("android/util/Base64");
231
232 // Read rather than written down: `URL_SAFE` is a different alphabet, and
233 // decoding with the wrong one produces bytes rather than an error.
234 const default_flags = try j.staticIntField(
235 base64_cls,
236 try j.staticFieldId(base64_cls, "DEFAULT", "I"),
237 );
238
239 return j.callStaticObjectMethodA(
240 base64_cls,
241 try j.staticMethodId(base64_cls, "decode", "(Ljava/lang/String;I)[B"),
242 &.{ .{ .l = try j.newStringUtf8(allocator, encoded) }, .{ .i = default_flags } },
243 );
244}
245
246/// `text.toByteArray()` — Kotlin's `writeText` uses UTF-8.
247fn utf8Bytes(j: Jni, allocator: std.mem.Allocator, text: []const u8) !jobject {
248 const string = try j.newStringUtf8(allocator, text);
249 const charset_cls = try j.findClass("java/nio/charset/StandardCharsets");
250 const utf8 = try j.staticObjectField(
251 charset_cls,
252 try j.staticFieldId(charset_cls, "UTF_8", "Ljava/nio/charset/Charset;"),
253 );
254 return j.callObjectMethodA(
255 string,
256 try j.methodId(
257 try j.objectClass(string),
258 "getBytes",
259 "(Ljava/nio/charset/Charset;)[B",
260 ),
261 &.{.{ .l = utf8 }},
262 );
263}
264
265/// `FileOutputStream(file).use { it.write(bytes) }`.
266fn writeBytes(j: Jni, file: jobject, bytes: jobject) !void {
267 try j.pushLocalFrame(8);
268 defer _ = j.popLocalFrame(null);
269
270 const stream_cls = try j.findClass("java/io/FileOutputStream");
271 const stream = try j.newObjectA(
272 stream_cls,
273 try j.methodId(stream_cls, "<init>", "(Ljava/io/File;)V"),
274 &.{.{ .l = file }},
275 );
276
277 // Resolved before the write, so the close below can be a plain `defer`.
278 // A stream left open holds its file descriptor until the GC gets to it,
279 // which on a device is long enough to matter — so it closes however this
280 // leaves, error included.
281 const close = try j.methodId(stream_cls, "close", "()V");
282 defer j.callVoidMethodA(stream, close, &.{}) catch {};
283
284 try j.callVoidMethodA(
285 stream,
286 try j.methodId(stream_cls, "write", "([B)V"),
287 &.{.{ .l = bytes }},
288 );
289}
290
291/// `text` as a JSON string.
292pub fn jsonString(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
293 var out: std.ArrayListUnmanaged(u8) = .empty;
294 errdefer out.deinit(allocator);
295
296 try out.append(allocator, '"');
297 try bridge_error.appendJsonEscaped(allocator, &out, text);
298 try out.append(allocator, '"');
299 return out.toOwnedSlice(allocator);
300}
301
302// =============================================================================
303// Tests
304// =============================================================================
305
306const testing = std.testing;
307
308test "anything not a data URL is written as text" {
309 const plan = planFor("hello, world");
310 try testing.expectEqualStrings("hello, world", plan.text);
311
312 // Including text that merely contains "data:" later on.
313 try testing.expectEqualStrings("x data:y", planFor("x data:y").text);
314
315 // And the empty string, which writes an empty file rather than nothing.
316 try testing.expectEqualStrings("", planFor("").text);
317}
318
319test "a data URL with exactly one comma decodes the tail" {
320 const plan = planFor("data:image/png;base64,iVBORw0KGgo=");
321 try testing.expectEqualStrings("iVBORw0KGgo=", plan.base64);
322
323 // The payload can be empty — `Base64.decode("")` is an empty array, so
324 // this writes an empty file rather than skipping the write.
325 try testing.expectEqualStrings("", planFor("data:,").base64);
326}
327
328test "a data URL with any other number of commas writes nothing at all" {
329 // The shim's `if (parts.size == 2)` with no else. The resolve still hands
330 // the page `file.absolutePath`, so a page is told where a file is that may
331 // not exist. See #175.
332 try testing.expectEqual(Plan.nothing, planFor("data:text/plain,a,b"));
333 try testing.expectEqual(Plan.nothing, planFor("data:image/png;base64,AAA,BBB"));
334
335 // No comma at all is the same skipped branch.
336 try testing.expectEqual(Plan.nothing, planFor("data:image/png;base64"));
337 try testing.expectEqual(Plan.nothing, planFor("data:"));
338}
339
340test "the prefix test is the shim's startsWith, not a contains" {
341 // `data` alone is text; only the colon makes it a URL.
342 try testing.expectEqualStrings("data", planFor("data").text);
343 try testing.expect(planFor("data:") == .nothing);
344
345 // Case matters: `startsWith` is not case-insensitive, so `DATA:` is text
346 // and is written verbatim.
347 try testing.expectEqualStrings("DATA:x,y", planFor("DATA:x,y").text);
348}
349
350test "the actions and globals match the shim exactly" {
351 try testing.expectEqualStrings("downloadFile", A.download_file);
352 try testing.expectEqualStrings("saveFile", A.save_file);
353 try testing.expectEqualStrings("_craftDownloadResolve", download_resolve_global);
354 try testing.expectEqualStrings("_craftDownloadReject", download_reject_global);
355 try testing.expectEqualStrings("_craftSaveResolve", save_resolve_global);
356 try testing.expectEqualStrings("_craftSaveReject", save_reject_global);
357}
358
359test "a path carrying a quote comes back as JSON" {
360 // `file.absolutePath` contains the filename the page chose, so it is page
361 // text arriving back through the reply channel.
362 const payload = try jsonString(testing.allocator, "/sdcard/it's \"here\".txt");
363 defer testing.allocator.free(payload);
364
365 var parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, payload, .{});
366 defer parsed.deinit();
367 try testing.expectEqualStrings("/sdcard/it's \"here\".txt", parsed.value.string);
368}
packages/zig/test/android_conformance_test.zigmodified+5-2
Changes to packages/zig/test/android_conformance_test.zig
@@ -54,6 +54,7 @@ const zig_sources = [_][]const u8{
5454 @embedFile("src/bridge_android_widgets.zig"),
5555 @embedFile("src/bridge_android_shortcuts.zig"),
5656 @embedFile("src/bridge_android_screen.zig"),
57 @embedFile("src/bridge_android_files.zig"),
5758};
5859
5960/// How many spec actions Zig does not serve yet.
@@ -81,8 +82,10 @@ const zig_sources = [_][]const u8{
8182/// scheduleNotification, whose delayed half needs a Runnable; 70 with the
8283/// orientation pair, the first to run on the main looper — through a Runnable
8384/// the Kotlin holder owns and a token that says what to do; 69 with
84/// setKeepAwake, whose Kotlin field turns out to be written and never read.
85const max_not_yet_migrated: usize = 69;
85/// setKeepAwake, whose Kotlin field turns out to be written and never read;
86/// 67 with downloadFile and saveFile, neither of which needed the main thread
87/// at all.
88const max_not_yet_migrated: usize = 67;
8689
8790/// Every `@JavascriptInterface fun <name>(` in the Kotlin bridge.
8891///