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/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}