nandi/frqpublic Fork 0
13e70a4
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Merge remote-tracking branch 'origin/main'

nandi committed 2026-08-30T18:38:28-07:00 Browse files
13e70a4 parents: 4c8b0c4 81efdfb
modified android/AndroidManifest.xml +38 -2
@@ -2,6 +2,18 @@
22 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
33 package="uk.nandi.frq">
44 <uses-permission android:name="android.permission.INTERNET" />
5+ <!--
6+ Reading the pictures the picker offers. Both, because the permission that
7+ covers them was split at API 33: before that a single storage read, after
8+ it one per medium. Neither is granted by being asked for here — they are
9+ runtime permissions, and this activity has no Java to raise the dialog
10+ with, so they are granted from Settings (or `adb shell pm grant`) until
11+ something asks. Without them the picker sees directory names and no
12+ files, which is what it says.
13+ -->
14+ <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
15+ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
16+ android:maxSdkVersion="32" />
517 <uses-feature android:glEsVersion="0x00030000" android:required="true" />
618
719 <!--
@@ -10,9 +22,15 @@
1022 vidya to inset by — the status bar clock sits on top of the app and the
1123 compose bar sits under the keyboard.
1224 -->
25+ <!--
26+ debuggable: `adb run-as` reads the app's own data directory under it,
27+ which is how a session file or a cache is looked at on the device. It
28+ costs nothing at runtime and this APK is signed with the debug key
29+ anyway; drop it for anything that ships.
30+ -->
1331 <application
1432 android:allowBackup="false"
15- android:hasCode="false"
33+ android:debuggable="true"
1634 android:label="frq"
1735 android:theme="@android:style/Theme.Material.NoActionBar">
1836 <!--
@@ -20,9 +38,15 @@
2038 the soft keyboard, so vidya has no IME inset to reserve and the compose
2139 bar sits under the keys.
2240 -->
41+ <!--
42+ singleTask: the sign-in deep link below has to bring the *running*
43+ app forward, with its loopback handoff already in hand, rather than
44+ start a second copy of it beside the first.
45+ -->
2346 <activity
24- android:name="android.app.NativeActivity"
47+ android:name=".FrqActivity"
2548 android:configChanges="orientation|keyboardHidden|screenSize"
49+ android:launchMode="singleTask"
2650 android:windowSoftInputMode="adjustResize"
2751 android:exported="true"
2852 android:screenOrientation="portrait">
@@ -39,6 +63,18 @@
3963 <action android:name="android.intent.action.MAIN" />
4064 <category android:name="android.intent.category.LAUNCHER" />
4165 </intent-filter>
66+ <!--
67+ Coming back from the browser. The handoff itself travels over
68+ loopback and is already captured by the time this arrives; the
69+ link carries nothing, and exists only to put the app back in
70+ front of the browser that covered it.
71+ -->
72+ <intent-filter>
73+ <action android:name="android.intent.action.VIEW" />
74+ <category android:name="android.intent.category.DEFAULT" />
75+ <category android:name="android.intent.category.BROWSABLE" />
76+ <data android:scheme="frq" android:host="auth" />
77+ </intent-filter>
4278 </activity>
4379 </application>
4480 </manifest>
@@ -2,6 +2,18 @@
2 <manifest xmlns:android="http://schemas.android.com/apk/res/android"2 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
3 package="uk.nandi.frq">3 package="uk.nandi.frq">
4 <uses-permission android:name="android.permission.INTERNET" />4 <uses-permission android:name="android.permission.INTERNET" />
5+ <!--
6+ Reading the pictures the picker offers. Both, because the permission that
7+ covers them was split at API 33: before that a single storage read, after
8+ it one per medium. Neither is granted by being asked for here — they are
9+ runtime permissions, and this activity has no Java to raise the dialog
10+ with, so they are granted from Settings (or `adb shell pm grant`) until
11+ something asks. Without them the picker sees directory names and no
12+ files, which is what it says.
13+ -->
14+ <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
15+ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
16+ android:maxSdkVersion="32" />
5 <uses-feature android:glEsVersion="0x00030000" android:required="true" />17 <uses-feature android:glEsVersion="0x00030000" android:required="true" />
6 18
7 <!--19 <!--
@@ -10,9 +22,15 @@
10 vidya to inset by — the status bar clock sits on top of the app and the22 vidya to inset by — the status bar clock sits on top of the app and the
11 compose bar sits under the keyboard.23 compose bar sits under the keyboard.
12 -->24 -->
25+ <!--
26+ debuggable: `adb run-as` reads the app's own data directory under it,
27+ which is how a session file or a cache is looked at on the device. It
28+ costs nothing at runtime and this APK is signed with the debug key
29+ anyway; drop it for anything that ships.
30+ -->
13 <application31 <application
14 android:allowBackup="false"32 android:allowBackup="false"
15- android:hasCode="false"33+ android:debuggable="true"
16 android:label="frq"34 android:label="frq"
17 android:theme="@android:style/Theme.Material.NoActionBar">35 android:theme="@android:style/Theme.Material.NoActionBar">
18 <!--36 <!--
@@ -20,9 +38,15 @@
20 the soft keyboard, so vidya has no IME inset to reserve and the compose38 the soft keyboard, so vidya has no IME inset to reserve and the compose
21 bar sits under the keys.39 bar sits under the keys.
22 -->40 -->
41+ <!--
42+ singleTask: the sign-in deep link below has to bring the *running*
43+ app forward, with its loopback handoff already in hand, rather than
44+ start a second copy of it beside the first.
45+ -->
23 <activity46 <activity
24- android:name="android.app.NativeActivity"47+ android:name=".FrqActivity"
25 android:configChanges="orientation|keyboardHidden|screenSize"48 android:configChanges="orientation|keyboardHidden|screenSize"
49+ android:launchMode="singleTask"
26 android:windowSoftInputMode="adjustResize"50 android:windowSoftInputMode="adjustResize"
27 android:exported="true"51 android:exported="true"
28 android:screenOrientation="portrait">52 android:screenOrientation="portrait">
@@ -39,6 +63,18 @@
39 <action android:name="android.intent.action.MAIN" />63 <action android:name="android.intent.action.MAIN" />
40 <category android:name="android.intent.category.LAUNCHER" />64 <category android:name="android.intent.category.LAUNCHER" />
41 </intent-filter>65 </intent-filter>
66+ <!--
67+ Coming back from the browser. The handoff itself travels over
68+ loopback and is already captured by the time this arrives; the
69+ link carries nothing, and exists only to put the app back in
70+ front of the browser that covered it.
71+ -->
72+ <intent-filter>
73+ <action android:name="android.intent.action.VIEW" />
74+ <category android:name="android.intent.category.DEFAULT" />
75+ <category android:name="android.intent.category.BROWSABLE" />
76+ <data android:scheme="frq" android:host="auth" />
77+ </intent-filter>
42 </activity>78 </activity>
43 </application>79 </application>
44 </manifest>80 </manifest>
modified android/build-apk.sh +36 -3
@@ -6,6 +6,12 @@
66 # glue, so it owns the event loop)
77 # libjoltapp.so vidya's android/jolt_main.c plus frq's Jolt boot image,
88 # dlopened by the above
9+# classes.dex one Java class, and only because a picture chooser answers
10+# through onActivityResult and a NativeActivity has nowhere to
11+# deliver that
12+# libssl.so OpenSSL, because the platform's own is not ours to load: an
13+# libcrypto.so app's linker namespace refuses /system/lib64/libssl.so, and
14+# without one there is no TLS at all on the phone
915 #
1016 # Neither half is built here beyond that last link: the UI library comes from
1117 # vidya's `just ffi-android` and the boot image from build-jolt-boot.sh. Both
@@ -17,6 +23,7 @@ VIDYA="${VIDYA:-$(cd "$ROOT/../vidya" && pwd)}"
1723 ANDROID_HOME="${ANDROID_HOME:-$HOME/.local/share/android-sdk}"
1824 ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$HOME/.local/share/android-ndk-r29}"
1925 CHEZ_ANDROID="${CHEZ_ANDROID:-$HOME/.cache/vidya-chez-android}"
26+OPENSSL_ANDROID="${OPENSSL_ANDROID:-$HOME/.cache/frq-openssl-android/lib}"
2027 BUILD="$ROOT/android/build"
2128 JOLT_BUILD="$BUILD/jolt"
2229 STAGE="$BUILD/stage"
@@ -24,13 +31,14 @@ TOOLS="$ANDROID_HOME/build-tools/36.0.0"
2431 ADB="${ADB:-$ANDROID_HOME/platform-tools/adb}"
2532 NDK_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
2633 PACKAGE="uk.nandi.frq"
27-ACTIVITY="$PACKAGE/android.app.NativeActivity"
34+ACTIVITY="$PACKAGE/.FrqActivity"
2835 API=28
2936
3037 for path in \
3138 "$NDK_BIN/aarch64-linux-android$API-clang" \
3239 "$ANDROID_HOME/platforms/android-36/android.jar" \
33- "$TOOLS/aapt2" "$TOOLS/zipalign" "$TOOLS/apksigner"; do
40+ "$TOOLS/aapt2" "$TOOLS/zipalign" "$TOOLS/apksigner" "$TOOLS/d8" \
41+ "$OPENSSL_ANDROID/libssl.so" "$OPENSSL_ANDROID/libcrypto.so"; do
3442 [[ -e "$path" ]] || { echo "missing Android tool: $path" >&2; exit 1; }
3543 done
3644
@@ -52,9 +60,31 @@ VIDYA_SO="$VIDYA/build/android/arm64-v8a/libvidya.so"
5260 jolt.boot jolt_boot.o
5361 )
5462
63+# --- the Java half ----------------------------------------------------------
64+# One class: the photo chooser's result has to land somewhere, and native code
65+# is not somewhere. d8 turns it into the classes.dex the runtime loads.
66+JAVA_BUILD="$BUILD/java"
67+rm -rf "$JAVA_BUILD"
68+mkdir -p "$JAVA_BUILD/classes"
69+# android.jar on the class path is where every android.* type comes from; the
70+# JDK's own java.* is what is left, and this class uses nothing of it that
71+# Android does not have. (`-bootclasspath` would be the stricter way to say
72+# that, and javac refuses it for a release this recent.)
73+javac --release 17 \
74+ --class-path "$ANDROID_HOME/platforms/android-36/android.jar" \
75+ -d "$JAVA_BUILD/classes" \
76+ "$ROOT/android/java/uk/nandi/frq/FrqActivity.java"
77+"$TOOLS/d8" --min-api $API --output "$JAVA_BUILD" \
78+ $(find "$JAVA_BUILD/classes" -name '*.class')
79+
5580 rm -rf "$STAGE"
5681 mkdir -p "$STAGE/lib/arm64-v8a"
82+cp "$JAVA_BUILD/classes.dex" "$STAGE/classes.dex"
5783 cp "$VIDYA_SO" "$STAGE/lib/arm64-v8a/libvidya.so"
84+# jolt.mvn-http dlopens these by name at first use; beside the app's own
85+# libraries is where an app's namespace will answer for that name.
86+cp "$OPENSSL_ANDROID/libssl.so" "$OPENSSL_ANDROID/libcrypto.so" \
87+ "$STAGE/lib/arm64-v8a/"
5888
5989 "$NDK_BIN/aarch64-linux-android$API-clang" \
6090 -shared -fPIC -O2 \
@@ -85,7 +115,10 @@ rm -f "$UNALIGNED" "$ALIGNED" "$APK"
85115 --version-name 0.1.0
86116 # Stored, not deflated: the loader maps these straight out of the APK.
87117 (cd "$STAGE" && zip -q -0 "$UNALIGNED" \
88- lib/arm64-v8a/libvidya.so lib/arm64-v8a/libjoltapp.so)
118+ lib/arm64-v8a/libvidya.so lib/arm64-v8a/libjoltapp.so \
119+ lib/arm64-v8a/libssl.so lib/arm64-v8a/libcrypto.so)
120+# The dex is read by the runtime rather than mapped, so it may as well deflate.
121+(cd "$STAGE" && zip -q "$UNALIGNED" classes.dex)
89122 "$TOOLS/zipalign" -f -p 4 "$UNALIGNED" "$ALIGNED"
90123
91124 KEYSTORE="$HOME/.android/debug.keystore"
@@ -6,6 +6,12 @@
6 # glue, so it owns the event loop)6 # glue, so it owns the event loop)
7 # libjoltapp.so vidya's android/jolt_main.c plus frq's Jolt boot image,7 # libjoltapp.so vidya's android/jolt_main.c plus frq's Jolt boot image,
8 # dlopened by the above8 # dlopened by the above
9+# classes.dex one Java class, and only because a picture chooser answers
10+# through onActivityResult and a NativeActivity has nowhere to
11+# deliver that
12+# libssl.so OpenSSL, because the platform's own is not ours to load: an
13+# libcrypto.so app's linker namespace refuses /system/lib64/libssl.so, and
14+# without one there is no TLS at all on the phone
9 #15 #
10 # Neither half is built here beyond that last link: the UI library comes from16 # Neither half is built here beyond that last link: the UI library comes from
11 # vidya's `just ffi-android` and the boot image from build-jolt-boot.sh. Both17 # vidya's `just ffi-android` and the boot image from build-jolt-boot.sh. Both
@@ -17,6 +23,7 @@ VIDYA="${VIDYA:-$(cd "$ROOT/../vidya" && pwd)}"
17 ANDROID_HOME="${ANDROID_HOME:-$HOME/.local/share/android-sdk}"23 ANDROID_HOME="${ANDROID_HOME:-$HOME/.local/share/android-sdk}"
18 ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$HOME/.local/share/android-ndk-r29}"24 ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$HOME/.local/share/android-ndk-r29}"
19 CHEZ_ANDROID="${CHEZ_ANDROID:-$HOME/.cache/vidya-chez-android}"25 CHEZ_ANDROID="${CHEZ_ANDROID:-$HOME/.cache/vidya-chez-android}"
26+OPENSSL_ANDROID="${OPENSSL_ANDROID:-$HOME/.cache/frq-openssl-android/lib}"
20 BUILD="$ROOT/android/build"27 BUILD="$ROOT/android/build"
21 JOLT_BUILD="$BUILD/jolt"28 JOLT_BUILD="$BUILD/jolt"
22 STAGE="$BUILD/stage"29 STAGE="$BUILD/stage"
@@ -24,13 +31,14 @@ TOOLS="$ANDROID_HOME/build-tools/36.0.0"
24 ADB="${ADB:-$ANDROID_HOME/platform-tools/adb}"31 ADB="${ADB:-$ANDROID_HOME/platform-tools/adb}"
25 NDK_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"32 NDK_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
26 PACKAGE="uk.nandi.frq"33 PACKAGE="uk.nandi.frq"
27-ACTIVITY="$PACKAGE/android.app.NativeActivity"34+ACTIVITY="$PACKAGE/.FrqActivity"
28 API=2835 API=28
29 36
30 for path in \37 for path in \
31 "$NDK_BIN/aarch64-linux-android$API-clang" \38 "$NDK_BIN/aarch64-linux-android$API-clang" \
32 "$ANDROID_HOME/platforms/android-36/android.jar" \39 "$ANDROID_HOME/platforms/android-36/android.jar" \
33- "$TOOLS/aapt2" "$TOOLS/zipalign" "$TOOLS/apksigner"; do40+ "$TOOLS/aapt2" "$TOOLS/zipalign" "$TOOLS/apksigner" "$TOOLS/d8" \
41+ "$OPENSSL_ANDROID/libssl.so" "$OPENSSL_ANDROID/libcrypto.so"; do
34 [[ -e "$path" ]] || { echo "missing Android tool: $path" >&2; exit 1; }42 [[ -e "$path" ]] || { echo "missing Android tool: $path" >&2; exit 1; }
35 done43 done
36 44
@@ -52,9 +60,31 @@ VIDYA_SO="$VIDYA/build/android/arm64-v8a/libvidya.so"
52 jolt.boot jolt_boot.o60 jolt.boot jolt_boot.o
53 )61 )
54 62
63+# --- the Java half ----------------------------------------------------------
64+# One class: the photo chooser's result has to land somewhere, and native code
65+# is not somewhere. d8 turns it into the classes.dex the runtime loads.
66+JAVA_BUILD="$BUILD/java"
67+rm -rf "$JAVA_BUILD"
68+mkdir -p "$JAVA_BUILD/classes"
69+# android.jar on the class path is where every android.* type comes from; the
70+# JDK's own java.* is what is left, and this class uses nothing of it that
71+# Android does not have. (`-bootclasspath` would be the stricter way to say
72+# that, and javac refuses it for a release this recent.)
73+javac --release 17 \
74+ --class-path "$ANDROID_HOME/platforms/android-36/android.jar" \
75+ -d "$JAVA_BUILD/classes" \
76+ "$ROOT/android/java/uk/nandi/frq/FrqActivity.java"
77+"$TOOLS/d8" --min-api $API --output "$JAVA_BUILD" \
78+ $(find "$JAVA_BUILD/classes" -name '*.class')
79+
55 rm -rf "$STAGE"80 rm -rf "$STAGE"
56 mkdir -p "$STAGE/lib/arm64-v8a"81 mkdir -p "$STAGE/lib/arm64-v8a"
82+cp "$JAVA_BUILD/classes.dex" "$STAGE/classes.dex"
57 cp "$VIDYA_SO" "$STAGE/lib/arm64-v8a/libvidya.so"83 cp "$VIDYA_SO" "$STAGE/lib/arm64-v8a/libvidya.so"
84+# jolt.mvn-http dlopens these by name at first use; beside the app's own
85+# libraries is where an app's namespace will answer for that name.
86+cp "$OPENSSL_ANDROID/libssl.so" "$OPENSSL_ANDROID/libcrypto.so" \
87+ "$STAGE/lib/arm64-v8a/"
58 88
59 "$NDK_BIN/aarch64-linux-android$API-clang" \89 "$NDK_BIN/aarch64-linux-android$API-clang" \
60 -shared -fPIC -O2 \90 -shared -fPIC -O2 \
@@ -85,7 +115,10 @@ rm -f "$UNALIGNED" "$ALIGNED" "$APK"
85 --version-name 0.1.0115 --version-name 0.1.0
86 # Stored, not deflated: the loader maps these straight out of the APK.116 # Stored, not deflated: the loader maps these straight out of the APK.
87 (cd "$STAGE" && zip -q -0 "$UNALIGNED" \117 (cd "$STAGE" && zip -q -0 "$UNALIGNED" \
88- lib/arm64-v8a/libvidya.so lib/arm64-v8a/libjoltapp.so)118+ lib/arm64-v8a/libvidya.so lib/arm64-v8a/libjoltapp.so \
119+ lib/arm64-v8a/libssl.so lib/arm64-v8a/libcrypto.so)
120+# The dex is read by the runtime rather than mapped, so it may as well deflate.
121+(cd "$STAGE" && zip -q "$UNALIGNED" classes.dex)
89 "$TOOLS/zipalign" -f -p 4 "$UNALIGNED" "$ALIGNED"122 "$TOOLS/zipalign" -f -p 4 "$UNALIGNED" "$ALIGNED"
90 123
91 KEYSTORE="$HOME/.android/debug.keystore"124 KEYSTORE="$HOME/.android/debug.keystore"
added android/java/uk/nandi/frq/FrqActivity.java +111 -0
new file mode 100644
@@ -0,0 +1,111 @@
1+package uk.nandi.frq;
2+
3+import android.app.NativeActivity;
4+import android.content.ContentResolver;
5+import android.content.Intent;
6+import android.net.Uri;
7+import android.os.Build;
8+import android.provider.MediaStore;
9+import android.util.Log;
10+
11+import java.io.File;
12+import java.io.FileOutputStream;
13+import java.io.InputStream;
14+import java.io.OutputStream;
15+
16+/**
17+ * The only Java in the app, and it exists for one reason: a picture chooser
18+ * answers through {@code onActivityResult}, and a plain NativeActivity has
19+ * nowhere to deliver that. Everything else the app does — the window, the
20+ * event loop, the UI — is native, and this class does not touch any of it.
21+ *
22+ * The native side reaches the two methods below over JNI, by name, on the
23+ * activity handle the glue already holds. See vidya's `vidya_pick_image` and
24+ * `vidya_picked_image`.
25+ */
26+public class FrqActivity extends NativeActivity {
27+ private static final String TAG = "VidyaJolt";
28+ private static final int PICK_IMAGE = 0x1CE;
29+
30+ /** Where the last pick was written, until the native side takes it. */
31+ private volatile String picked;
32+
33+ /**
34+ * Open the system photo picker. Called from the UI thread or off it, so it
35+ * hops to the right one itself.
36+ *
37+ * ACTION_PICK_IMAGES where there is one (API 33+): it shows the reader's
38+ * own photos without this app holding any storage permission at all, since
39+ * what comes back is a grant for the one picture they chose. Below that,
40+ * the document picker does the same job through the same result.
41+ *
42+ * PNG only, because PNG is what the tree backend paints and what the
43+ * upload sends — a chooser offering pictures the app then refuses would be
44+ * a worse answer than one that never offered them.
45+ */
46+ public void pickImage() {
47+ runOnUiThread(new Runnable() {
48+ @Override
49+ public void run() {
50+ Intent intent;
51+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
52+ intent = new Intent(MediaStore.ACTION_PICK_IMAGES);
53+ } else {
54+ intent = new Intent(Intent.ACTION_GET_CONTENT);
55+ intent.addCategory(Intent.CATEGORY_OPENABLE);
56+ }
57+ intent.setType("image/png");
58+ try {
59+ startActivityForResult(intent, PICK_IMAGE);
60+ } catch (Exception e) {
61+ Log.e(TAG, "no picture chooser on this device", e);
62+ }
63+ }
64+ });
65+ }
66+
67+ /**
68+ * The picture chosen since the last call, as a path, or null.
69+ *
70+ * Handed over once: a caller polling for it must not attach the same
71+ * picture twice, and the file is the native side's to move from here.
72+ */
73+ public String takePickedImage() {
74+ String path = picked;
75+ picked = null;
76+ return path;
77+ }
78+
79+ @Override
80+ protected void onActivityResult(int request, int result, Intent data) {
81+ super.onActivityResult(request, result, data);
82+ if (request != PICK_IMAGE) {
83+ return;
84+ }
85+ Uri uri = result == RESULT_OK && data != null ? data.getData() : null;
86+ if (uri == null) {
87+ // Cancelled, or a chooser that answered with nothing. Not an
88+ // error: the picker screen is still there to try again from.
89+ return;
90+ }
91+ // Copied out now, while the grant on that URI is still live — it is
92+ // this activity's for the length of the result and no longer.
93+ File out = new File(getCacheDir(), "picked-" + System.nanoTime() + ".png");
94+ ContentResolver resolver = getContentResolver();
95+ try (InputStream in = resolver.openInputStream(uri);
96+ OutputStream os = new FileOutputStream(out)) {
97+ if (in == null) {
98+ throw new java.io.IOException("nothing to read at " + uri);
99+ }
100+ byte[] buf = new byte[1 << 16];
101+ for (int n = in.read(buf); n > 0; n = in.read(buf)) {
102+ os.write(buf, 0, n);
103+ }
104+ } catch (Exception e) {
105+ Log.e(TAG, "could not read the chosen picture", e);
106+ out.delete();
107+ return;
108+ }
109+ picked = out.getAbsolutePath();
110+ }
111+}
new file mode 100644
@@ -0,0 +1,111 @@
1+package uk.nandi.frq;
2+
3+import android.app.NativeActivity;
4+import android.content.ContentResolver;
5+import android.content.Intent;
6+import android.net.Uri;
7+import android.os.Build;
8+import android.provider.MediaStore;
9+import android.util.Log;
10+
11+import java.io.File;
12+import java.io.FileOutputStream;
13+import java.io.InputStream;
14+import java.io.OutputStream;
15+
16+/**
17+ * The only Java in the app, and it exists for one reason: a picture chooser
18+ * answers through {@code onActivityResult}, and a plain NativeActivity has
19+ * nowhere to deliver that. Everything else the app does — the window, the
20+ * event loop, the UI — is native, and this class does not touch any of it.
21+ *
22+ * The native side reaches the two methods below over JNI, by name, on the
23+ * activity handle the glue already holds. See vidya's `vidya_pick_image` and
24+ * `vidya_picked_image`.
25+ */
26+public class FrqActivity extends NativeActivity {
27+ private static final String TAG = "VidyaJolt";
28+ private static final int PICK_IMAGE = 0x1CE;
29+
30+ /** Where the last pick was written, until the native side takes it. */
31+ private volatile String picked;
32+
33+ /**
34+ * Open the system photo picker. Called from the UI thread or off it, so it
35+ * hops to the right one itself.
36+ *
37+ * ACTION_PICK_IMAGES where there is one (API 33+): it shows the reader's
38+ * own photos without this app holding any storage permission at all, since
39+ * what comes back is a grant for the one picture they chose. Below that,
40+ * the document picker does the same job through the same result.
41+ *
42+ * PNG only, because PNG is what the tree backend paints and what the
43+ * upload sends — a chooser offering pictures the app then refuses would be
44+ * a worse answer than one that never offered them.
45+ */
46+ public void pickImage() {
47+ runOnUiThread(new Runnable() {
48+ @Override
49+ public void run() {
50+ Intent intent;
51+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
52+ intent = new Intent(MediaStore.ACTION_PICK_IMAGES);
53+ } else {
54+ intent = new Intent(Intent.ACTION_GET_CONTENT);
55+ intent.addCategory(Intent.CATEGORY_OPENABLE);
56+ }
57+ intent.setType("image/png");
58+ try {
59+ startActivityForResult(intent, PICK_IMAGE);
60+ } catch (Exception e) {
61+ Log.e(TAG, "no picture chooser on this device", e);
62+ }
63+ }
64+ });
65+ }
66+
67+ /**
68+ * The picture chosen since the last call, as a path, or null.
69+ *
70+ * Handed over once: a caller polling for it must not attach the same
71+ * picture twice, and the file is the native side's to move from here.
72+ */
73+ public String takePickedImage() {
74+ String path = picked;
75+ picked = null;
76+ return path;
77+ }
78+
79+ @Override
80+ protected void onActivityResult(int request, int result, Intent data) {
81+ super.onActivityResult(request, result, data);
82+ if (request != PICK_IMAGE) {
83+ return;
84+ }
85+ Uri uri = result == RESULT_OK && data != null ? data.getData() : null;
86+ if (uri == null) {
87+ // Cancelled, or a chooser that answered with nothing. Not an
88+ // error: the picker screen is still there to try again from.
89+ return;
90+ }
91+ // Copied out now, while the grant on that URI is still live — it is
92+ // this activity's for the length of the result and no longer.
93+ File out = new File(getCacheDir(), "picked-" + System.nanoTime() + ".png");
94+ ContentResolver resolver = getContentResolver();
95+ try (InputStream in = resolver.openInputStream(uri);
96+ OutputStream os = new FileOutputStream(out)) {
97+ if (in == null) {
98+ throw new java.io.IOException("nothing to read at " + uri);
99+ }
100+ byte[] buf = new byte[1 << 16];
101+ for (int n = in.read(buf); n > 0; n = in.read(buf)) {
102+ os.write(buf, 0, n);
103+ }
104+ } catch (Exception e) {
105+ Log.e(TAG, "could not read the chosen picture", e);
106+ out.delete();
107+ return;
108+ }
109+ picked = out.getAbsolutePath();
110+ }
111+}
modified src/frq/app.jolt +1 -0
@@ -723,6 +723,7 @@
723723 [:vbox {:key :call}
724724 (when (and name
725725 (str/starts-with? name "#")
726+ (av/available?)
726727 (not (av/call-in name))
727728 (not (av/in-call?)))
728729 [:button {:label "Call" :on-click #(s/start-call! name)}])]]
@@ -723,6 +723,7 @@
723 [:vbox {:key :call}723 [:vbox {:key :call}
724 (when (and name724 (when (and name
725 (str/starts-with? name "#")725 (str/starts-with? name "#")
726+ (av/available?)
726 (not (av/call-in name))727 (not (av/call-in name))
727 (not (av/in-call?)))728 (not (av/in-call?)))
728 [:button {:label "Call" :on-click #(s/start-call! name)}])]]729 [:button {:label "Call" :on-click #(s/start-call! name)}])]]
modified src/frq/av.jolt +25 -5
@@ -73,6 +73,21 @@
7373 (def ^:private status-ended 2)
7474 (def ^:private status-failed 3)
7575
76+(defonce ^:private media-plane
77+ ;; Whether `libjoltmoq` is here at all, asked once by calling the smallest
78+ ;; thing in it. It is not on the phone nobody has cross-built the media
79+ ;; plane for Android and a client that cannot make calls is still a client,
80+ ;; so the answer gates the call surface rather than ending the run. Every
81+ ;; other binding in this namespace is reached only from behind it.
82+ (delay
83+ (try (raw-init-logging) true
84+ (catch Exception _ false))))
85+
86+(defn available?
87+ "Whether calls can happen here at all."
88+ []
89+ @media-plane)
90+
7691 (defn- flag [b] (if b 1 0))
7792 (defn- pref [s] (or s ""))
7893
@@ -335,7 +350,8 @@
335350 alone rather than re-dialled."
336351 [server]
337352 (when-let [{:keys [session-id token media]} @local-call]
338- (when (and (seq session-id)
353+ (when (and (available?)
354+ (seq session-id)
339355 (not (contains? #{:dialling :live} media))
340356 (can-dial? server token))
341357 (start-media! server))))
@@ -541,17 +557,21 @@
541557 Worth doing unconditionally: it says nothing at all without a `RUST_LOG`,
542558 and when a call misbehaves it is the only thing that knows why — MoQ
543559 subscription, codec negotiation and device open all happen on the far side of
544- the boundary, where no jolt-level trace can see them."
560+ the boundary, where no jolt-level trace can see them.
561+
562+ Asking whether the media plane is there is the same call, so this is it."
545563 []
546- (raw-init-logging))
564+ (available?))
547565
548566 (defn install-pump!
549- "Start pumping the media plane every frame. Returns a timer id.
567+ "Start pumping the media plane every frame. Returns a timer id, or nil where
568+ there is no media plane to pump.
550569
551570 Sixteen milliseconds rather than a longer gap because this is where video
552571 arrives: polling slower than the window paints would show every other frame."
553572 []
554- (vidya/every! 16 pump!))
573+ (when (available?)
574+ (vidya/every! 16 pump!)))
555575
556576 (defn tiles
557577 "Everyone with a picture in the current call, the self-view last.
@@ -73,6 +73,21 @@
73 (def ^:private status-ended 2)73 (def ^:private status-ended 2)
74 (def ^:private status-failed 3)74 (def ^:private status-failed 3)
75 75
76+(defonce ^:private media-plane
77+ ;; Whether `libjoltmoq` is here at all, asked once by calling the smallest
78+ ;; thing in it. It is not on the phone nobody has cross-built the media
79+ ;; plane for Android and a client that cannot make calls is still a client,
80+ ;; so the answer gates the call surface rather than ending the run. Every
81+ ;; other binding in this namespace is reached only from behind it.
82+ (delay
83+ (try (raw-init-logging) true
84+ (catch Exception _ false))))
85+
86+(defn available?
87+ "Whether calls can happen here at all."
88+ []
89+ @media-plane)
90+
76 (defn- flag [b] (if b 1 0))91 (defn- flag [b] (if b 1 0))
77 (defn- pref [s] (or s ""))92 (defn- pref [s] (or s ""))
78 93
@@ -335,7 +350,8 @@
335 alone rather than re-dialled."350 alone rather than re-dialled."
336 [server]351 [server]
337 (when-let [{:keys [session-id token media]} @local-call]352 (when-let [{:keys [session-id token media]} @local-call]
338- (when (and (seq session-id)353+ (when (and (available?)
354+ (seq session-id)
339 (not (contains? #{:dialling :live} media))355 (not (contains? #{:dialling :live} media))
340 (can-dial? server token))356 (can-dial? server token))
341 (start-media! server))))357 (start-media! server))))
@@ -541,17 +557,21 @@
541 Worth doing unconditionally: it says nothing at all without a `RUST_LOG`,557 Worth doing unconditionally: it says nothing at all without a `RUST_LOG`,
542 and when a call misbehaves it is the only thing that knows why — MoQ558 and when a call misbehaves it is the only thing that knows why — MoQ
543 subscription, codec negotiation and device open all happen on the far side of559 subscription, codec negotiation and device open all happen on the far side of
544- the boundary, where no jolt-level trace can see them."560+ the boundary, where no jolt-level trace can see them.
561+
562+ Asking whether the media plane is there is the same call, so this is it."
545 []563 []
546- (raw-init-logging))564+ (available?))
547 565
548 (defn install-pump!566 (defn install-pump!
549- "Start pumping the media plane every frame. Returns a timer id.567+ "Start pumping the media plane every frame. Returns a timer id, or nil where
568+ there is no media plane to pump.
550 569
551 Sixteen milliseconds rather than a longer gap because this is where video570 Sixteen milliseconds rather than a longer gap because this is where video
552 arrives: polling slower than the window paints would show every other frame."571 arrives: polling slower than the window paints would show every other frame."
553 []572 []
554- (vidya/every! 16 pump!))573+ (when (available?)
574+ (vidya/every! 16 pump!)))
555 575
556 (defn tiles576 (defn tiles
557 "Everyone with a picture in the current call, the self-view last.577 "Everyone with a picture in the current call, the self-view last.
modified src/frq/oauth.jolt +26 -4
@@ -51,15 +51,36 @@
5151
5252 ;; ------------------------------------------------------------------ capture
5353
54-(def ^:private capture-html
54+(defn- capture-html
55+ "The page the browser lands on with the handoff in its fragment. Its one job
56+ is to POST that fragment back, since a fragment never reaches a server.
57+
58+ `return-url` is the deep link back to the app, or nil where there is nowhere
59+ to go on a desktop the browser sits beside the app and the reader switches
60+ windows. On Android the app is behind the browser and something has to bring
61+ it forward: the page tries the link on its own, and offers it as a tap for
62+ the case Chrome refuses a scheme it was not asked for by hand."
63+ [return-url]
5564 (str "<!doctype html><meta charset=utf-8><title>frq</title>"
5665 "<body style=\"font:15px system-ui;background:#242424;color:#fff;padding:40px\">"
57- "<p id=m>Finishing sign-in…</p><script>"
66+ "<p id=m>Finishing sign-in…</p>"
67+ (when return-url
68+ (str "<p><a id=b href=\"" return-url "\" hidden "
69+ "style=\"display:inline-block;padding:12px 20px;border-radius:8px;"
70+ "background:#5a7fd0;color:#fff;text-decoration:none\">Return to frq</a></p>"))
71+ "<script>"
5872 "var h=location.hash.replace(/^#/,'');"
5973 "var p=new URLSearchParams(h).get('oauth')||h.replace(/^oauth=/,'');"
6074 "if(!p){document.getElementById('m').textContent='No sign-in payload in this URL.';}"
6175 "else{fetch('/capture',{method:'POST',body:p})"
62- ".then(function(){document.getElementById('m').textContent='Signed in — you can close this tab.';})"
76+ ".then(function(){document.getElementById('m').textContent="
77+ (if return-url "'Signed in — returning to frq…';" "'Signed in — you can close this tab.';")
78+ (when return-url
79+ (str "var b=document.getElementById('b');b.hidden=false;"
80+ ;; Chrome answers a scripted navigation to a scheme of its own
81+ ;; only sometimes; the link is there for when it does not.
82+ "location.href=b.href;"))
83+ "})"
6384 ".catch(function(e){document.getElementById('m').textContent='Handoff failed: '+e;});}"
6485 "</script></body>"))
6586
@@ -131,7 +152,8 @@
131152 ;; A POST that carried nothing usable is not the end of the
132153 ;; wait keep serving, the real handoff may still arrive.
133154 (or tokens (recur)))
134- (do (respond! fd capture-html "text/html; charset=utf-8")
155+ (do (respond! fd (capture-html (platform/return-url))
156+ "text/html; charset=utf-8")
135157 (socket/c-close fd)
136158 (recur)))))))
137159 (finally (socket/c-close server)))))
@@ -51,15 +51,36 @@
51 51
52 ;; ------------------------------------------------------------------ capture52 ;; ------------------------------------------------------------------ capture
53 53
54-(def ^:private capture-html54+(defn- capture-html
55+ "The page the browser lands on with the handoff in its fragment. Its one job
56+ is to POST that fragment back, since a fragment never reaches a server.
57+
58+ `return-url` is the deep link back to the app, or nil where there is nowhere
59+ to go on a desktop the browser sits beside the app and the reader switches
60+ windows. On Android the app is behind the browser and something has to bring
61+ it forward: the page tries the link on its own, and offers it as a tap for
62+ the case Chrome refuses a scheme it was not asked for by hand."
63+ [return-url]
55 (str "<!doctype html><meta charset=utf-8><title>frq</title>"64 (str "<!doctype html><meta charset=utf-8><title>frq</title>"
56 "<body style=\"font:15px system-ui;background:#242424;color:#fff;padding:40px\">"65 "<body style=\"font:15px system-ui;background:#242424;color:#fff;padding:40px\">"
57- "<p id=m>Finishing sign-in…</p><script>"66+ "<p id=m>Finishing sign-in…</p>"
67+ (when return-url
68+ (str "<p><a id=b href=\"" return-url "\" hidden "
69+ "style=\"display:inline-block;padding:12px 20px;border-radius:8px;"
70+ "background:#5a7fd0;color:#fff;text-decoration:none\">Return to frq</a></p>"))
71+ "<script>"
58 "var h=location.hash.replace(/^#/,'');"72 "var h=location.hash.replace(/^#/,'');"
59 "var p=new URLSearchParams(h).get('oauth')||h.replace(/^oauth=/,'');"73 "var p=new URLSearchParams(h).get('oauth')||h.replace(/^oauth=/,'');"
60 "if(!p){document.getElementById('m').textContent='No sign-in payload in this URL.';}"74 "if(!p){document.getElementById('m').textContent='No sign-in payload in this URL.';}"
61 "else{fetch('/capture',{method:'POST',body:p})"75 "else{fetch('/capture',{method:'POST',body:p})"
62- ".then(function(){document.getElementById('m').textContent='Signed in — you can close this tab.';})"76+ ".then(function(){document.getElementById('m').textContent="
77+ (if return-url "'Signed in — returning to frq…';" "'Signed in — you can close this tab.';")
78+ (when return-url
79+ (str "var b=document.getElementById('b');b.hidden=false;"
80+ ;; Chrome answers a scripted navigation to a scheme of its own
81+ ;; only sometimes; the link is there for when it does not.
82+ "location.href=b.href;"))
83+ "})"
63 ".catch(function(e){document.getElementById('m').textContent='Handoff failed: '+e;});}"84 ".catch(function(e){document.getElementById('m').textContent='Handoff failed: '+e;});}"
64 "</script></body>"))85 "</script></body>"))
65 86
@@ -131,7 +152,8 @@
131 ;; A POST that carried nothing usable is not the end of the152 ;; A POST that carried nothing usable is not the end of the
132 ;; wait keep serving, the real handoff may still arrive.153 ;; wait keep serving, the real handoff may still arrive.
133 (or tokens (recur)))154 (or tokens (recur)))
134- (do (respond! fd capture-html "text/html; charset=utf-8")155+ (do (respond! fd (capture-html (platform/return-url))
156+ "text/html; charset=utf-8")
135 (socket/c-close fd)157 (socket/c-close fd)
136 (recur)))))))158 (recur)))))))
137 (finally (socket/c-close server)))))159 (finally (socket/c-close server)))))
modified src/frq/platform.jolt +21 -5
@@ -1,12 +1,28 @@
11 (ns frq.platform
2- "The few things that are the desktop's job rather than the app's."
3- (:require [clojure.string :as str]
2+ "The few things that are the platform's job rather than the app's."
3+ (:require [glimmer-vidya.core :as vidya]
44 [jolt.host :as host]))
55
6+(defn android?
7+ "Android, told from a desktop by a binary only it has. What hangs on this is
8+ which way the app and the browser sit: side by side, or one behind the other."
9+ []
10+ (host/file-exists? "/system/bin/am"))
11+
612 (defn open-url!
7- "Hand a URL to the desktop. Android has no xdg-open, so this is a no-op
8- there — the same reason sign-in is desktop-only."
13+ "Hand a URL to whatever shows web pages here. The backend knows what that
14+ means — xdg-open on a desktop, an ACTION_VIEW intent on Android, where no
15+ shelled-out `am start` is allowed to. False leaves the connect screen's
16+ \"if the browser did not open\" line to carry the URL across."
917 [url]
1018 (try
11- (zero? (host/sh (str "xdg-open '" (str/replace (or url "") "'" "%27") "' >/dev/null 2>&1 &")))
19+ (vidya/open-url! (or url ""))
1220 (catch Exception _ false)))
21+
22+(defn return-url
23+ "The link that brings the app back to the front once the browser is done, or
24+ nil where the browser never covered it. `frq://auth` is the manifest's own
25+ scheme; the activity is `singleTask`, so it is the running app that comes
26+ forward rather than a second copy of it."
27+ []
28+ (when (android?) "frq://auth"))
@@ -1,12 +1,28 @@
1 (ns frq.platform1 (ns frq.platform
2- "The few things that are the desktop's job rather than the app's."2+ "The few things that are the platform's job rather than the app's."
3- (:require [clojure.string :as str]3+ (:require [glimmer-vidya.core :as vidya]
4 [jolt.host :as host]))4 [jolt.host :as host]))
5 5
6+(defn android?
7+ "Android, told from a desktop by a binary only it has. What hangs on this is
8+ which way the app and the browser sit: side by side, or one behind the other."
9+ []
10+ (host/file-exists? "/system/bin/am"))
11+
6 (defn open-url!12 (defn open-url!
7- "Hand a URL to the desktop. Android has no xdg-open, so this is a no-op13+ "Hand a URL to whatever shows web pages here. The backend knows what that
8- there — the same reason sign-in is desktop-only."14+ means — xdg-open on a desktop, an ACTION_VIEW intent on Android, where no
15+ shelled-out `am start` is allowed to. False leaves the connect screen's
16+ \"if the browser did not open\" line to carry the URL across."
9 [url]17 [url]
10 (try18 (try
11- (zero? (host/sh (str "xdg-open '" (str/replace (or url "") "'" "%27") "' >/dev/null 2>&1 &")))19+ (vidya/open-url! (or url ""))
12 (catch Exception _ false)))20 (catch Exception _ false)))
21+
22+(defn return-url
23+ "The link that brings the app back to the front once the browser is done, or
24+ nil where the browser never covered it. `frq://auth` is the manifest's own
25+ scheme; the activity is `singleTask`, so it is the running app that comes
26+ forward rather than a second copy of it."
27+ []
28+ (when (android?) "frq://auth"))
modified src/frq/state.jolt +59 -3
@@ -759,11 +759,67 @@
759759 (let [up (str/join "/" (butlast (str/split (str dir) #"/")))]
760760 (when (and (seq up) (not= up dir) (readable-dir? up)) up)))
761761
762+;; ------------------------------------------- the platform's own chooser
763+
764+;; Polling, because a chooser is another app's screen: it takes the reader away
765+;; and gives nothing back through a handler here. `choosing` is what the poll
766+;; runs on, and the count is what ends it — a reader who backs out without
767+;; choosing tells us nothing at all, so the alternative is a poll that outlives
768+;; the app's interest in the answer.
769+(defonce ^:private choosing (atom nil))
770+
771+(def ^:private choose-poll-ms 300)
772+
773+(def ^:private choose-poll-limit
774+ "Five minutes of asking. Long enough for someone who wandered off mid-choice,
775+ short enough that a cancelled chooser is not still being polled for at
776+ bedtime."
777+ 1000)
778+
779+(defn- take-chosen!
780+ "Attach the picture the chooser has written, if it has written one yet."
781+ []
782+ (let [path (paste-path)]
783+ (host/mkdirs! (str (media/cache-dir) "/outgoing"))
784+ (when (vidya/picked-image! path)
785+ (reset! choosing nil)
786+ (attach! path "picture.png")
787+ true)))
788+
789+(defn- poll-chosen! []
790+ (when-let [left @choosing]
791+ (when-not (take-chosen!)
792+ (if (pos? left)
793+ (do (reset! choosing (dec left))
794+ (vidya/after! choose-poll-ms poll-chosen!))
795+ (reset! choosing nil)))))
796+
797+(defn choose-image!
798+ "Open the platform's own picture chooser, where there is one; true when it
799+ opened.
800+
801+ Preferred to browsing on a phone, and not only for the taste of it: what the
802+ chooser hands back is a grant for the one picture the reader chose, so the
803+ app needs no permission over their pictures at all — and without such a
804+ permission, browsing finds almost nothing to show. False where there is no
805+ chooser, which is every desktop, and there browsing is the answer."
806+ []
807+ (when (vidya/pick-image!)
808+ (reset! error nil)
809+ (reset! choosing choose-poll-limit)
810+ (vidya/after! choose-poll-ms poll-chosen!)
811+ true))
812+
762813 (defn open-image-picker!
763- "Open the picker, on the first place there is to look."
814+ "Ask for a picture, whichever way this platform has of choosing one.
815+
816+ The platform's own chooser where there is one — it needs no permission and
817+ knows where the reader's pictures actually are — and otherwise this app's
818+ own browsing screen, which is what a desktop gets."
764819 []
765- (reset! error nil)
766- (reset! image-picker (or (first (picker-roots)) "/")))
820+ (when-not (choose-image!)
821+ (reset! error nil)
822+ (reset! image-picker (or (first (picker-roots)) "/"))))
767823
768824 (defn close-image-picker! [] (reset! image-picker nil))
769825
@@ -759,11 +759,67 @@
759 (let [up (str/join "/" (butlast (str/split (str dir) #"/")))]759 (let [up (str/join "/" (butlast (str/split (str dir) #"/")))]
760 (when (and (seq up) (not= up dir) (readable-dir? up)) up)))760 (when (and (seq up) (not= up dir) (readable-dir? up)) up)))
761 761
762+;; ------------------------------------------- the platform's own chooser
763+
764+;; Polling, because a chooser is another app's screen: it takes the reader away
765+;; and gives nothing back through a handler here. `choosing` is what the poll
766+;; runs on, and the count is what ends it — a reader who backs out without
767+;; choosing tells us nothing at all, so the alternative is a poll that outlives
768+;; the app's interest in the answer.
769+(defonce ^:private choosing (atom nil))
770+
771+(def ^:private choose-poll-ms 300)
772+
773+(def ^:private choose-poll-limit
774+ "Five minutes of asking. Long enough for someone who wandered off mid-choice,
775+ short enough that a cancelled chooser is not still being polled for at
776+ bedtime."
777+ 1000)
778+
779+(defn- take-chosen!
780+ "Attach the picture the chooser has written, if it has written one yet."
781+ []
782+ (let [path (paste-path)]
783+ (host/mkdirs! (str (media/cache-dir) "/outgoing"))
784+ (when (vidya/picked-image! path)
785+ (reset! choosing nil)
786+ (attach! path "picture.png")
787+ true)))
788+
789+(defn- poll-chosen! []
790+ (when-let [left @choosing]
791+ (when-not (take-chosen!)
792+ (if (pos? left)
793+ (do (reset! choosing (dec left))
794+ (vidya/after! choose-poll-ms poll-chosen!))
795+ (reset! choosing nil)))))
796+
797+(defn choose-image!
798+ "Open the platform's own picture chooser, where there is one; true when it
799+ opened.
800+
801+ Preferred to browsing on a phone, and not only for the taste of it: what the
802+ chooser hands back is a grant for the one picture the reader chose, so the
803+ app needs no permission over their pictures at all — and without such a
804+ permission, browsing finds almost nothing to show. False where there is no
805+ chooser, which is every desktop, and there browsing is the answer."
806+ []
807+ (when (vidya/pick-image!)
808+ (reset! error nil)
809+ (reset! choosing choose-poll-limit)
810+ (vidya/after! choose-poll-ms poll-chosen!)
811+ true))
812+
762 (defn open-image-picker!813 (defn open-image-picker!
763- "Open the picker, on the first place there is to look."814+ "Ask for a picture, whichever way this platform has of choosing one.
815+
816+ The platform's own chooser where there is one — it needs no permission and
817+ knows where the reader's pictures actually are — and otherwise this app's
818+ own browsing screen, which is what a desktop gets."
764 []819 []
765- (reset! error nil)820+ (when-not (choose-image!)
766- (reset! image-picker (or (first (picker-roots)) "/")))821+ (reset! error nil)
822+ (reset! image-picker (or (first (picker-roots)) "/"))))
767 823
768 (defn close-image-picker! [] (reset! image-picker nil))824 (defn close-image-picker! [] (reset! image-picker nil))
769 825