janela 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -18
- package/bin/janela.mjs +341 -15
- package/package.json +1 -1
- package/runtime/ios.ts +82 -8
- package/shim/android/app.cc +380 -0
- package/shim/android/java/dev/janela/host/JanelaActivity.java +144 -0
- package/shim/ios/app.cc +388 -0
package/runtime/ios.ts
CHANGED
|
@@ -42,6 +42,7 @@ declare function hostSchedule(id: number, ms: number): void;
|
|
|
42
42
|
declare function hostSettle(pendingId: number, envelopeJson: string): void;
|
|
43
43
|
declare function hostReadFile(jobId: number, path: string): void;
|
|
44
44
|
declare function hostWriteFile(jobId: number, path: string, data: string): void;
|
|
45
|
+
declare function hostOpenDialog(jobId: number, optionsJson: string): void;
|
|
45
46
|
|
|
46
47
|
// One job table serves reads and writes; these sentinels say which callback of
|
|
47
48
|
// the pair is the real one. Identity comparison is the whole trick, so they
|
|
@@ -68,9 +69,14 @@ function encode(value: unknown): string {
|
|
|
68
69
|
// an answer. A library build still links no event loop (SC4005); that is why
|
|
69
70
|
// the design routes through the shell rather than a limitation of iOS.
|
|
70
71
|
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
72
|
+
// `openFileDialog` now works too: the shell presents the platform picker and
|
|
73
|
+
// copies the chosen file into the app's container, so the path it hands back
|
|
74
|
+
// is one `readFileAsync` can open.
|
|
75
|
+
//
|
|
76
|
+
// What remains behind pending(): `saveFileDialog`, which is not a missing
|
|
77
|
+
// implementation but an unmade API decision (see its doc comment — mobile
|
|
78
|
+
// wants an export-shaped call, not a path to write to), and window control,
|
|
79
|
+
// which is permanently meaningless on a phone rather than unfinished.
|
|
74
80
|
//
|
|
75
81
|
// FAILING LOUDLY WITHOUT KILLING THE APP: an uncaught throw in library mode
|
|
76
82
|
// reaches the panic sink and then ABORTS the process (SC4013). So a stub must
|
|
@@ -135,6 +141,14 @@ export class JanelaAppImpl<
|
|
|
135
141
|
jobWriteCbs: ((err: string | null) => void)[] = [];
|
|
136
142
|
nextJob = 1;
|
|
137
143
|
|
|
144
|
+
// In-flight file dialogs. Separate from the file jobs above: a picker is
|
|
145
|
+
// presented by the shell and answered when the user is done, which may be
|
|
146
|
+
// never. Kept in its own table so a dialog result can never be mistaken for
|
|
147
|
+
// a file-I/O result.
|
|
148
|
+
dialogIds: number[] = [];
|
|
149
|
+
dialogCbs: ((paths: string[] | null, err?: string) => void)[] = [];
|
|
150
|
+
nextDialog = 1;
|
|
151
|
+
|
|
138
152
|
// Kept so the shared WindowConfig shape compiles; iOS has no window to size.
|
|
139
153
|
constructor(_cfg: WindowConfig) {}
|
|
140
154
|
|
|
@@ -352,20 +366,80 @@ export class JanelaAppImpl<
|
|
|
352
366
|
}
|
|
353
367
|
}
|
|
354
368
|
|
|
355
|
-
/**
|
|
369
|
+
/**
|
|
370
|
+
* Present the platform's document picker.
|
|
371
|
+
*
|
|
372
|
+
* The paths handed back are **copies inside the app's own storage**, not the
|
|
373
|
+
* locations the user picked. Neither platform gives an app a durable path to
|
|
374
|
+
* a file outside its container: iOS returns a security-scoped URL that is
|
|
375
|
+
* readable only while its access is held and does not survive a relaunch
|
|
376
|
+
* without a bookmark, and Android returns a `content://` URI that is not a
|
|
377
|
+
* path at all. Copying is what makes the result something `readFileAsync`
|
|
378
|
+
* can open, and keeps this API identical to desktop. Nothing tracks the
|
|
379
|
+
* original afterwards, so a later read sees the file as it was when picked.
|
|
380
|
+
*/
|
|
356
381
|
openFileDialog(
|
|
357
|
-
|
|
382
|
+
options: OpenDialogOptions,
|
|
358
383
|
cb: (paths: string[] | null, err?: string) => void,
|
|
359
384
|
): void {
|
|
360
|
-
|
|
385
|
+
const id = this.nextDialog;
|
|
386
|
+
this.nextDialog = id + 1;
|
|
387
|
+
this.dialogIds.push(id);
|
|
388
|
+
this.dialogCbs.push(cb);
|
|
389
|
+
hostOpenDialog(id, JSON.stringify(options));
|
|
361
390
|
}
|
|
362
391
|
|
|
363
|
-
/**
|
|
392
|
+
/**
|
|
393
|
+
* Called by the shell on the main queue when a picker closes.
|
|
394
|
+
*
|
|
395
|
+
* `ok` false carries an error message in `payload`. `ok` true carries a JSON
|
|
396
|
+
* array of paths — empty when the user cancelled, which answers `null` to
|
|
397
|
+
* match desktop.
|
|
398
|
+
*/
|
|
399
|
+
onDialogDone(id: number, ok: boolean, payload: string): void {
|
|
400
|
+
for (let i = 0; i < this.dialogIds.length; i++) {
|
|
401
|
+
if (this.dialogIds[i] === id) {
|
|
402
|
+
const cb = this.dialogCbs[i];
|
|
403
|
+
this.dialogIds.splice(i, 1);
|
|
404
|
+
this.dialogCbs.splice(i, 1);
|
|
405
|
+
if (!ok) {
|
|
406
|
+
cb(null, payload);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const paths = JSON.parse(payload) as string[];
|
|
410
|
+
// Cancel is not an error: desktop answers null, so this does too.
|
|
411
|
+
if (paths.length < 1) cb(null);
|
|
412
|
+
else cb(paths);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* @remarks Not on mobile; reports through the callback.
|
|
420
|
+
*
|
|
421
|
+
* Deliberately still absent while `openFileDialog` works, because the two
|
|
422
|
+
* are not symmetrical here. Desktop's "save" means *tell me where to write*,
|
|
423
|
+
* and neither mobile platform offers that: iOS's `forExporting:` picker
|
|
424
|
+
* requires the file to already exist before it opens, and Android's
|
|
425
|
+
* `ACTION_CREATE_DOCUMENT` hands back a URI to write into rather than a
|
|
426
|
+
* path. Both want an export-shaped call — "here is a file, put it
|
|
427
|
+
* somewhere" — which is a different operation, not a different spelling of
|
|
428
|
+
* this one. Implementing it as `saveFileDialog` would give the same method
|
|
429
|
+
* two meanings across platforms, so it waits for an API decision.
|
|
430
|
+
*/
|
|
364
431
|
saveFileDialog(
|
|
365
432
|
_options: SaveDialogOptions,
|
|
366
433
|
cb: (path: string | null, err?: string) => void,
|
|
367
434
|
): void {
|
|
368
|
-
cb(
|
|
435
|
+
cb(
|
|
436
|
+
null,
|
|
437
|
+
pending(
|
|
438
|
+
"saveFileDialog",
|
|
439
|
+
"a phone has no 'choose a path to write' picker; iOS and Android both " +
|
|
440
|
+
"want an export-shaped call, which is a different operation",
|
|
441
|
+
),
|
|
442
|
+
);
|
|
369
443
|
}
|
|
370
444
|
|
|
371
445
|
/** @remarks No-op on iOS: an app has no window title to set. */
|
package/shim/android/app.cc
CHANGED
|
@@ -36,6 +36,8 @@
|
|
|
36
36
|
#include <vector>
|
|
37
37
|
|
|
38
38
|
#include <android/log.h>
|
|
39
|
+
#include <sys/stat.h>
|
|
40
|
+
#include <unistd.h>
|
|
39
41
|
|
|
40
42
|
#define JANELA_LOG(...) \
|
|
41
43
|
__android_log_print(ANDROID_LOG_INFO, "janela", __VA_ARGS__)
|
|
@@ -53,6 +55,8 @@ void jl_handle_invoke(const char *cmd, size_t cmd_len, const char *args,
|
|
|
53
55
|
void jl_index_html(char **out, size_t *out_len);
|
|
54
56
|
void jl_on_timer(double id);
|
|
55
57
|
void jl_on_fs_done(double id, bool ok, const char *payload, size_t payload_len);
|
|
58
|
+
void jl_on_dialog_done(double id, bool ok, const char *payload,
|
|
59
|
+
size_t payload_len);
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
namespace {
|
|
@@ -60,6 +64,112 @@ namespace {
|
|
|
60
64
|
webview::webview *g_webview = nullptr;
|
|
61
65
|
std::string g_files_dir; // the app's private storage, resolved at startup
|
|
62
66
|
|
|
67
|
+
// ---- host output -> logcat -------------------------------------------------
|
|
68
|
+
//
|
|
69
|
+
// Android discards a process's stdout and stderr. `setprop log.redirect-stdio
|
|
70
|
+
// true` is refused on API 36, so a host `console.log` simply vanished and the
|
|
71
|
+
// only way to see what the host printed was to render it and screenshot the
|
|
72
|
+
// screen. That is not a debugging story, and it is the same problem iOS had
|
|
73
|
+
// before its os_log tee.
|
|
74
|
+
//
|
|
75
|
+
// So the shell tees stdout and stderr into logcat line by line under stable
|
|
76
|
+
// tags, and still writes to the original descriptors so nothing is lost:
|
|
77
|
+
//
|
|
78
|
+
// adb logcat -s janela-host:V # what the app printed
|
|
79
|
+
// adb logcat -s janela:V janela-stderr:V # shell notices and stderr
|
|
80
|
+
//
|
|
81
|
+
// The library may not create threads; the shell may, and does — one reader per
|
|
82
|
+
// descriptor.
|
|
83
|
+
|
|
84
|
+
/// Replace `fd` with a pipe, forwarding every line to logcat and on to the
|
|
85
|
+
/// original descriptor.
|
|
86
|
+
///
|
|
87
|
+
/// `tag` differs per descriptor on purpose. Replacing fd 2 captures everything
|
|
88
|
+
/// in the process that writes to stderr, not just the library — on the
|
|
89
|
+
/// emulator the GL driver is extremely chatty there — so host output goes to
|
|
90
|
+
/// `janela-host` and stderr to `janela-stderr`, keeping
|
|
91
|
+
/// `adb logcat -s janela-host:V` a clean view of what the app itself printed.
|
|
92
|
+
void tee_fd_to_logcat(int fd, android_LogPriority prio, const char *label,
|
|
93
|
+
const char *tag) {
|
|
94
|
+
int original = dup(fd);
|
|
95
|
+
if (original < 0) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
int fds[2];
|
|
99
|
+
if (pipe(fds) != 0) {
|
|
100
|
+
close(original);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (dup2(fds[1], fd) < 0) {
|
|
104
|
+
close(fds[0]);
|
|
105
|
+
close(fds[1]);
|
|
106
|
+
close(original);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
close(fds[1]);
|
|
110
|
+
|
|
111
|
+
int read_fd = fds[0];
|
|
112
|
+
// A pipe is not a tty, so stdio switches to full buffering and would hold
|
|
113
|
+
// the library's output until the buffer filled or the process exited. Pin
|
|
114
|
+
// line buffering back on, or a log line only appears long after the event.
|
|
115
|
+
if (fd == STDOUT_FILENO) {
|
|
116
|
+
setvbuf(stdout, nullptr, _IOLBF, 0);
|
|
117
|
+
} else if (fd == STDERR_FILENO) {
|
|
118
|
+
setvbuf(stderr, nullptr, _IOLBF, 0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
std::thread([read_fd, original, prio, tag] {
|
|
122
|
+
std::string pending;
|
|
123
|
+
std::vector<char> buf(4096);
|
|
124
|
+
for (;;) {
|
|
125
|
+
ssize_t n = read(read_fd, buf.data(), buf.size());
|
|
126
|
+
if (n <= 0) {
|
|
127
|
+
break; // writer closed, or an unrecoverable error
|
|
128
|
+
}
|
|
129
|
+
// Pass the bytes through untouched first: stdout must behave as before.
|
|
130
|
+
ssize_t off = 0;
|
|
131
|
+
while (off < n) {
|
|
132
|
+
ssize_t w = write(original, buf.data() + off, (size_t)(n - off));
|
|
133
|
+
if (w <= 0) {
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
off += w;
|
|
137
|
+
}
|
|
138
|
+
// Then split into lines: logcat is line-oriented and truncates long
|
|
139
|
+
// records, so one write per line rather than per read.
|
|
140
|
+
pending.append(buf.data(), (size_t)n);
|
|
141
|
+
size_t nl;
|
|
142
|
+
while ((nl = pending.find('\n')) != std::string::npos) {
|
|
143
|
+
std::string line = pending.substr(0, nl);
|
|
144
|
+
pending.erase(0, nl + 1);
|
|
145
|
+
if (!line.empty() && line.back() == '\r') {
|
|
146
|
+
line.pop_back();
|
|
147
|
+
}
|
|
148
|
+
if (!line.empty()) {
|
|
149
|
+
__android_log_write(prio, tag, line.c_str());
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// A very long line with no newline would otherwise grow without bound.
|
|
153
|
+
if (pending.size() > 64 * 1024) {
|
|
154
|
+
__android_log_write(prio, tag, pending.c_str());
|
|
155
|
+
pending.clear();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!pending.empty()) {
|
|
159
|
+
__android_log_write(prio, tag, pending.c_str());
|
|
160
|
+
}
|
|
161
|
+
}).detach();
|
|
162
|
+
|
|
163
|
+
JANELA_LOG("%s is mirrored to logcat under tag %s", label, tag);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Mirror the library's output into logcat. Called before jl_init(), so
|
|
167
|
+
/// anything setup() prints is already captured.
|
|
168
|
+
void start_logging() {
|
|
169
|
+
tee_fd_to_logcat(STDOUT_FILENO, ANDROID_LOG_INFO, "stdout", "janela-host");
|
|
170
|
+
tee_fd_to_logcat(STDERR_FILENO, ANDROID_LOG_ERROR, "stderr", "janela-stderr");
|
|
171
|
+
}
|
|
172
|
+
|
|
63
173
|
/// Copy a library-owned result out of its arena and release it. Results live
|
|
64
174
|
/// until the next jl_reset(), so nothing may hold the pointer past this call.
|
|
65
175
|
std::string take_result(char *out, size_t out_len) {
|
|
@@ -386,6 +496,228 @@ std::string files_dir(JNIEnv *env, jobject activity) {
|
|
|
386
496
|
|
|
387
497
|
JavaVM *g_vm = nullptr;
|
|
388
498
|
|
|
499
|
+
// ---- the document picker ---------------------------------------------------
|
|
500
|
+
//
|
|
501
|
+
// Android hands back a content:// URI, which is not a path and cannot be given
|
|
502
|
+
// to readFileAsync. So the shell copies the picked bytes into the app's own
|
|
503
|
+
// storage and returns that path, which readFileAsync can already open. The
|
|
504
|
+
// copy is a snapshot; nothing tracks the original afterwards.
|
|
505
|
+
//
|
|
506
|
+
// The picker itself is presented from Java: the Storage Access Framework
|
|
507
|
+
// answers on Activity#onActivityResult, and native code cannot receive that
|
|
508
|
+
// (ART's JNI DefineClass is unimplemented). JanelaActivity owns that half and
|
|
509
|
+
// calls back in through the two JNI entry points at the bottom of this file.
|
|
510
|
+
|
|
511
|
+
/// A global ref to the Activity, so the picker can be presented from a later
|
|
512
|
+
/// turn. Local refs do not survive the call that produced them.
|
|
513
|
+
jobject g_activity = nullptr;
|
|
514
|
+
|
|
515
|
+
/// Attach the calling thread if needed and hand back an env for it.
|
|
516
|
+
struct scoped_env {
|
|
517
|
+
JNIEnv *env = nullptr;
|
|
518
|
+
bool detach = false;
|
|
519
|
+
scoped_env() {
|
|
520
|
+
if (!g_vm) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (g_vm->GetEnv((void **)&env, JNI_VERSION_1_6) == JNI_EDETACHED) {
|
|
524
|
+
if (g_vm->AttachCurrentThread(&env, nullptr) == JNI_OK) {
|
|
525
|
+
detach = true;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
~scoped_env() {
|
|
530
|
+
if (detach && g_vm) {
|
|
531
|
+
g_vm->DetachCurrentThread();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
/// Read a content:// URI through the ContentResolver and copy it into the
|
|
537
|
+
/// app's storage, returning the new path (empty on failure).
|
|
538
|
+
std::string copy_uri_into_storage(JNIEnv *env, const std::string &uri_str,
|
|
539
|
+
const std::string &display_name) {
|
|
540
|
+
if (!g_activity) {
|
|
541
|
+
return {};
|
|
542
|
+
}
|
|
543
|
+
jclass uri_cls = env->FindClass("android/net/Uri");
|
|
544
|
+
jmethodID parse = env->GetStaticMethodID(uri_cls, "parse",
|
|
545
|
+
"(Ljava/lang/String;)Landroid/net/Uri;");
|
|
546
|
+
jstring juri = env->NewStringUTF(uri_str.c_str());
|
|
547
|
+
jobject uri = env->CallStaticObjectMethod(uri_cls, parse, juri);
|
|
548
|
+
env->DeleteLocalRef(juri);
|
|
549
|
+
if (!uri || env->ExceptionCheck()) {
|
|
550
|
+
env->ExceptionClear();
|
|
551
|
+
return {};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
jclass act_cls = env->GetObjectClass(g_activity);
|
|
555
|
+
jmethodID get_resolver = env->GetMethodID(act_cls, "getContentResolver",
|
|
556
|
+
"()Landroid/content/ContentResolver;");
|
|
557
|
+
jobject resolver = env->CallObjectMethod(g_activity, get_resolver);
|
|
558
|
+
if (!resolver || env->ExceptionCheck()) {
|
|
559
|
+
env->ExceptionClear();
|
|
560
|
+
return {};
|
|
561
|
+
}
|
|
562
|
+
jclass res_cls = env->GetObjectClass(resolver);
|
|
563
|
+
jmethodID open = env->GetMethodID(res_cls, "openInputStream",
|
|
564
|
+
"(Landroid/net/Uri;)Ljava/io/InputStream;");
|
|
565
|
+
jobject stream = env->CallObjectMethod(resolver, open, uri);
|
|
566
|
+
if (!stream || env->ExceptionCheck()) {
|
|
567
|
+
env->ExceptionClear();
|
|
568
|
+
return {};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Prefer the name the user saw. A content:// URI's last segment is an opaque
|
|
572
|
+
// document id, so falling back to it produces copies called things like
|
|
573
|
+
// "3A18"; the Activity resolves OpenableColumns.DISPLAY_NAME for us.
|
|
574
|
+
std::string name = display_name;
|
|
575
|
+
if (name.empty()) {
|
|
576
|
+
name = uri_str;
|
|
577
|
+
size_t slash = name.find_last_of("/%");
|
|
578
|
+
if (slash != std::string::npos && slash + 1 < name.size()) {
|
|
579
|
+
name = name.substr(slash + 1);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
// A display name is untrusted input and must not escape the pick directory.
|
|
583
|
+
for (char &c : name) {
|
|
584
|
+
if (c == '/' || c == '\\') {
|
|
585
|
+
c = '_';
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if (name == "." || name == "..") {
|
|
589
|
+
name = "picked";
|
|
590
|
+
}
|
|
591
|
+
for (char &c : name) {
|
|
592
|
+
if (c == ':' || c == '?' || c == '&' || c == '=') {
|
|
593
|
+
c = '_';
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (name.empty()) {
|
|
597
|
+
name = "picked";
|
|
598
|
+
}
|
|
599
|
+
std::string dir = resolve_path("picked");
|
|
600
|
+
::mkdir(dir.c_str(), 0755);
|
|
601
|
+
std::string dest = dir + "/" + name;
|
|
602
|
+
|
|
603
|
+
jclass stream_cls = env->GetObjectClass(stream);
|
|
604
|
+
jmethodID read = env->GetMethodID(stream_cls, "read", "([B)I");
|
|
605
|
+
jmethodID close = env->GetMethodID(stream_cls, "close", "()V");
|
|
606
|
+
jbyteArray buf = env->NewByteArray(8192);
|
|
607
|
+
std::ofstream out(dest, std::ios::binary | std::ios::trunc);
|
|
608
|
+
bool ok = out.good();
|
|
609
|
+
while (ok) {
|
|
610
|
+
jint n = env->CallIntMethod(stream, read, buf);
|
|
611
|
+
if (env->ExceptionCheck()) {
|
|
612
|
+
env->ExceptionClear();
|
|
613
|
+
ok = false;
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
if (n <= 0) {
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
jbyte *bytes = env->GetByteArrayElements(buf, nullptr);
|
|
620
|
+
out.write(reinterpret_cast<const char *>(bytes), n);
|
|
621
|
+
env->ReleaseByteArrayElements(buf, bytes, JNI_ABORT);
|
|
622
|
+
}
|
|
623
|
+
out.close();
|
|
624
|
+
env->CallVoidMethod(stream, close);
|
|
625
|
+
env->ExceptionClear();
|
|
626
|
+
|
|
627
|
+
env->DeleteLocalRef(buf);
|
|
628
|
+
env->DeleteLocalRef(stream_cls);
|
|
629
|
+
env->DeleteLocalRef(stream);
|
|
630
|
+
env->DeleteLocalRef(res_cls);
|
|
631
|
+
env->DeleteLocalRef(resolver);
|
|
632
|
+
env->DeleteLocalRef(act_cls);
|
|
633
|
+
env->DeleteLocalRef(uri);
|
|
634
|
+
env->DeleteLocalRef(uri_cls);
|
|
635
|
+
|
|
636
|
+
if (!ok || !out) {
|
|
637
|
+
return {};
|
|
638
|
+
}
|
|
639
|
+
return dest;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
void finish_dialog(double job, bool ok, std::string payload) {
|
|
643
|
+
// BY VALUE and dispatched: never re-enter the library from inside a JNI
|
|
644
|
+
// callback (upstream #263) — this lands at the top of a later turn.
|
|
645
|
+
if (g_webview) {
|
|
646
|
+
g_webview->dispatch([job, ok, payload] {
|
|
647
|
+
jl_on_dialog_done(job, ok, payload.c_str(), payload.size());
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/// TS -> shell: present a document picker. Records the request and returns;
|
|
653
|
+
/// the Activity presents it on the UI thread.
|
|
654
|
+
void host_open_dialog(void *, double job, const char *options, size_t len) {
|
|
655
|
+
std::string opts(options, len);
|
|
656
|
+
if (!g_webview) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
g_webview->dispatch([job, opts] {
|
|
660
|
+
// Android's SAF has no directory mode through ACTION_OPEN_DOCUMENT;
|
|
661
|
+
// report rather than quietly opening a file picker instead.
|
|
662
|
+
if (webview::detail::json_parse(opts, "directory", 0) == "true") {
|
|
663
|
+
finish_dialog(job, false,
|
|
664
|
+
"ENOTSUP: picking a directory is not supported on Android");
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
scoped_env se;
|
|
668
|
+
if (!se.env || !g_activity) {
|
|
669
|
+
finish_dialog(job, false, "EINVAL: the app has no Activity to present on");
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
JNIEnv *env = se.env;
|
|
673
|
+
|
|
674
|
+
// Filters are MIME types here, so an extension list cannot be honoured
|
|
675
|
+
// directly; a type given as "image/*" or "text/plain" passes through.
|
|
676
|
+
std::vector<std::string> mimes;
|
|
677
|
+
for (size_t i = 0;; i++) {
|
|
678
|
+
std::string ext = webview::detail::json_parse(opts, "extensions", i);
|
|
679
|
+
if (ext.empty()) {
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
if (ext.find('/') != std::string::npos) {
|
|
683
|
+
mimes.push_back(ext);
|
|
684
|
+
} else {
|
|
685
|
+
JANELA_LOG("filter '.%s' is an extension, but Android's picker takes "
|
|
686
|
+
"MIME types, so the picker is not filtered",
|
|
687
|
+
ext.c_str());
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
jclass str_cls = env->FindClass("java/lang/String");
|
|
692
|
+
jobjectArray arr =
|
|
693
|
+
env->NewObjectArray((jsize)mimes.size(), str_cls, nullptr);
|
|
694
|
+
for (size_t i = 0; i < mimes.size(); i++) {
|
|
695
|
+
jstring s = env->NewStringUTF(mimes[i].c_str());
|
|
696
|
+
env->SetObjectArrayElement(arr, (jsize)i, s);
|
|
697
|
+
env->DeleteLocalRef(s);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
jclass act_cls = env->GetObjectClass(g_activity);
|
|
701
|
+
jmethodID mid = env->GetMethodID(act_cls, "openDocumentPicker",
|
|
702
|
+
"(DZ[Ljava/lang/String;)V");
|
|
703
|
+
if (!mid) {
|
|
704
|
+
env->ExceptionClear();
|
|
705
|
+
finish_dialog(job, false,
|
|
706
|
+
"EIO: the Activity has no openDocumentPicker method");
|
|
707
|
+
} else {
|
|
708
|
+
bool multiple = webview::detail::json_parse(opts, "multiple", 0) == "true";
|
|
709
|
+
env->CallVoidMethod(g_activity, mid, (jdouble)job, (jboolean)multiple, arr);
|
|
710
|
+
if (env->ExceptionCheck()) {
|
|
711
|
+
env->ExceptionClear();
|
|
712
|
+
finish_dialog(job, false, "EIO: could not open a document picker");
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
env->DeleteLocalRef(arr);
|
|
716
|
+
env->DeleteLocalRef(str_cls);
|
|
717
|
+
env->DeleteLocalRef(act_cls);
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
389
721
|
} // namespace
|
|
390
722
|
|
|
391
723
|
extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
|
|
@@ -393,6 +725,46 @@ extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) {
|
|
|
393
725
|
return JNI_VERSION_1_6;
|
|
394
726
|
}
|
|
395
727
|
|
|
728
|
+
extern "C" JNIEXPORT void JNICALL
|
|
729
|
+
Java_dev_janela_host_JanelaActivity_nativeOnDialogResult(JNIEnv *env, jclass,
|
|
730
|
+
jdouble job,
|
|
731
|
+
jboolean ok,
|
|
732
|
+
jstring payload) {
|
|
733
|
+
std::string p;
|
|
734
|
+
if (payload) {
|
|
735
|
+
const char *chars = env->GetStringUTFChars(payload, nullptr);
|
|
736
|
+
if (chars) {
|
|
737
|
+
p = chars;
|
|
738
|
+
env->ReleaseStringUTFChars(payload, chars);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
finish_dialog((double)job, ok == JNI_TRUE, p);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
extern "C" JNIEXPORT jstring JNICALL
|
|
745
|
+
Java_dev_janela_host_JanelaActivity_nativeCopyUri(JNIEnv *env, jclass,
|
|
746
|
+
jstring uri,
|
|
747
|
+
jstring display_name) {
|
|
748
|
+
std::string u;
|
|
749
|
+
if (uri) {
|
|
750
|
+
const char *chars = env->GetStringUTFChars(uri, nullptr);
|
|
751
|
+
if (chars) {
|
|
752
|
+
u = chars;
|
|
753
|
+
env->ReleaseStringUTFChars(uri, chars);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
std::string dn;
|
|
757
|
+
if (display_name) {
|
|
758
|
+
const char *chars = env->GetStringUTFChars(display_name, nullptr);
|
|
759
|
+
if (chars) {
|
|
760
|
+
dn = chars;
|
|
761
|
+
env->ReleaseStringUTFChars(display_name, chars);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
std::string path = copy_uri_into_storage(env, u, dn);
|
|
765
|
+
return env->NewStringUTF(path.c_str());
|
|
766
|
+
}
|
|
767
|
+
|
|
396
768
|
extern "C" JNIEXPORT void JNICALL
|
|
397
769
|
Java_dev_janela_host_JanelaActivity_nativeOnCreate(JNIEnv *env, jclass,
|
|
398
770
|
jobject activity) {
|
|
@@ -401,6 +773,12 @@ Java_dev_janela_host_JanelaActivity_nativeOnCreate(JNIEnv *env, jclass,
|
|
|
401
773
|
return;
|
|
402
774
|
}
|
|
403
775
|
g_files_dir = files_dir(env, activity);
|
|
776
|
+
// A global ref: the picker is presented from a later turn, and a local
|
|
777
|
+
// ref does not survive the call that produced it.
|
|
778
|
+
g_activity = env->NewGlobalRef(activity);
|
|
779
|
+
|
|
780
|
+
// Before jl_init(), so anything setup() prints reaches logcat.
|
|
781
|
+
start_logging();
|
|
404
782
|
|
|
405
783
|
// Registration is a pure store and is legal before init; the panic sink and
|
|
406
784
|
// every channel must be in place before any TypeScript runs, since setup()
|
|
@@ -411,6 +789,8 @@ Java_dev_janela_host_JanelaActivity_nativeOnCreate(JNIEnv *env, jclass,
|
|
|
411
789
|
jl_set_callback("hostReadFile", (void (*)(void))host_read_file, nullptr) ||
|
|
412
790
|
jl_set_callback("hostWriteFile", (void (*)(void))host_write_file,
|
|
413
791
|
nullptr) ||
|
|
792
|
+
jl_set_callback("hostOpenDialog", (void (*)(void))host_open_dialog,
|
|
793
|
+
nullptr) ||
|
|
414
794
|
jl_set_callback("janelaEmit", (void (*)(void))emit_event, nullptr)) {
|
|
415
795
|
JANELA_LOG("could not register a host channel");
|
|
416
796
|
}
|