nandi/frqpublic Fork 0
7b6e915
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.

Sign in and choose a picture on the phone

Four things the app could not do on Android, none of which were about the app:

**The browser never opened.** `open-url!` shelled out to xdg-open, which
Android does not have — and `am start` in its place is refused, since `am`
names com.android.shell as its calling package and that is not the app's uid.
It goes through vidya's ABI now, which is an ACTION_VIEW intent there.

**And never came back.** The browser covers the app rather than sitting beside
it, so the page that captures the handoff now offers `frq://auth` — a scheme
the manifest claims, on a singleTask activity, so the running app comes
forward with its loopback capture already done rather than a second copy
starting. The page tries the link itself and shows it as a button for the case
Chrome refuses a scheme it was not asked for by hand. Desktop keeps the old
"you can close this tab": there is nothing to come back from there.

**Nothing to browse.** The picker's directories are behind a runtime
permission this app has no Java to ask for, so it saw names and no files. The
platform's own chooser needs no permission at all — what it hands back is a
grant for the one picture chosen — so that is what a phone opens now, and
browsing is what a desktop gets. The answer arrives long after the call, from
a screen this app does not own, so it is polled for rather than waited on.

That chooser answers through onActivityResult, and a NativeActivity has
nowhere to deliver one — hence the first Java in the app, and a classes.dex
beside the two shared libraries. It is forty lines that touch nothing else.

**Nowhere to write.** An Android process starts with no HOME, so every path
derived from one landed under /.cache: the media cache, channels.edn and
session.edn were all failing quietly on the phone and nowhere else. vidya
points HOME at the app's own storage now, which is what this needed.

TLS is still refused there — jolt.mvn-http reads `ai_addr` at glibc's offset
and Bionic puts it at BSD's — so sign-in and the upload it gates still do not
finish on a phone. That fix is jolt's, not this repo's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T17:52:26-07:00 Browse files
7b6e915 parent: cab7487
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 +25 -2
@@ -6,6 +6,9 @@
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
912 #
1013 # Neither half is built here beyond that last link: the UI library comes from
1114 # vidya's `just ffi-android` and the boot image from build-jolt-boot.sh. Both
@@ -24,13 +27,13 @@ TOOLS="$ANDROID_HOME/build-tools/36.0.0"
2427 ADB="${ADB:-$ANDROID_HOME/platform-tools/adb}"
2528 NDK_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
2629 PACKAGE="uk.nandi.frq"
27-ACTIVITY="$PACKAGE/android.app.NativeActivity"
30+ACTIVITY="$PACKAGE/.FrqActivity"
2831 API=28
2932
3033 for path in \
3134 "$NDK_BIN/aarch64-linux-android$API-clang" \
3235 "$ANDROID_HOME/platforms/android-36/android.jar" \
33- "$TOOLS/aapt2" "$TOOLS/zipalign" "$TOOLS/apksigner"; do
36+ "$TOOLS/aapt2" "$TOOLS/zipalign" "$TOOLS/apksigner" "$TOOLS/d8"; do
3437 [[ -e "$path" ]] || { echo "missing Android tool: $path" >&2; exit 1; }
3538 done
3639
@@ -52,8 +55,26 @@ VIDYA_SO="$VIDYA/build/android/arm64-v8a/libvidya.so"
5255 jolt.boot jolt_boot.o
5356 )
5457
58+# --- the Java half ----------------------------------------------------------
59+# One class: the photo chooser's result has to land somewhere, and native code
60+# is not somewhere. d8 turns it into the classes.dex the runtime loads.
61+JAVA_BUILD="$BUILD/java"
62+rm -rf "$JAVA_BUILD"
63+mkdir -p "$JAVA_BUILD/classes"
64+# android.jar on the class path is where every android.* type comes from; the
65+# JDK's own java.* is what is left, and this class uses nothing of it that
66+# Android does not have. (`-bootclasspath` would be the stricter way to say
67+# that, and javac refuses it for a release this recent.)
68+javac --release 17 \
69+ --class-path "$ANDROID_HOME/platforms/android-36/android.jar" \
70+ -d "$JAVA_BUILD/classes" \
71+ "$ROOT/android/java/uk/nandi/frq/FrqActivity.java"
72+"$TOOLS/d8" --min-api $API --output "$JAVA_BUILD" \
73+ $(find "$JAVA_BUILD/classes" -name '*.class')
74+
5575 rm -rf "$STAGE"
5676 mkdir -p "$STAGE/lib/arm64-v8a"
77+cp "$JAVA_BUILD/classes.dex" "$STAGE/classes.dex"
5778 cp "$VIDYA_SO" "$STAGE/lib/arm64-v8a/libvidya.so"
5879
5980 "$NDK_BIN/aarch64-linux-android$API-clang" \
@@ -86,6 +107,8 @@ rm -f "$UNALIGNED" "$ALIGNED" "$APK"
86107 # Stored, not deflated: the loader maps these straight out of the APK.
87108 (cd "$STAGE" && zip -q -0 "$UNALIGNED" \
88109 lib/arm64-v8a/libvidya.so lib/arm64-v8a/libjoltapp.so)
110+# The dex is read by the runtime rather than mapped, so it may as well deflate.
111+(cd "$STAGE" && zip -q "$UNALIGNED" classes.dex)
89112 "$TOOLS/zipalign" -f -p 4 "$UNALIGNED" "$ALIGNED"
90113
91114 KEYSTORE="$HOME/.android/debug.keystore"
@@ -6,6 +6,9 @@
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
9 #12 #
10 # Neither half is built here beyond that last link: the UI library comes from13 # 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. Both14 # vidya's `just ffi-android` and the boot image from build-jolt-boot.sh. Both
@@ -24,13 +27,13 @@ TOOLS="$ANDROID_HOME/build-tools/36.0.0"
24 ADB="${ADB:-$ANDROID_HOME/platform-tools/adb}"27 ADB="${ADB:-$ANDROID_HOME/platform-tools/adb}"
25 NDK_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"28 NDK_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
26 PACKAGE="uk.nandi.frq"29 PACKAGE="uk.nandi.frq"
27-ACTIVITY="$PACKAGE/android.app.NativeActivity"30+ACTIVITY="$PACKAGE/.FrqActivity"
28 API=2831 API=28
29 32
30 for path in \33 for path in \
31 "$NDK_BIN/aarch64-linux-android$API-clang" \34 "$NDK_BIN/aarch64-linux-android$API-clang" \
32 "$ANDROID_HOME/platforms/android-36/android.jar" \35 "$ANDROID_HOME/platforms/android-36/android.jar" \
33- "$TOOLS/aapt2" "$TOOLS/zipalign" "$TOOLS/apksigner"; do36+ "$TOOLS/aapt2" "$TOOLS/zipalign" "$TOOLS/apksigner" "$TOOLS/d8"; do
34 [[ -e "$path" ]] || { echo "missing Android tool: $path" >&2; exit 1; }37 [[ -e "$path" ]] || { echo "missing Android tool: $path" >&2; exit 1; }
35 done38 done
36 39
@@ -52,8 +55,26 @@ VIDYA_SO="$VIDYA/build/android/arm64-v8a/libvidya.so"
52 jolt.boot jolt_boot.o55 jolt.boot jolt_boot.o
53 )56 )
54 57
58+# --- the Java half ----------------------------------------------------------
59+# One class: the photo chooser's result has to land somewhere, and native code
60+# is not somewhere. d8 turns it into the classes.dex the runtime loads.
61+JAVA_BUILD="$BUILD/java"
62+rm -rf "$JAVA_BUILD"
63+mkdir -p "$JAVA_BUILD/classes"
64+# android.jar on the class path is where every android.* type comes from; the
65+# JDK's own java.* is what is left, and this class uses nothing of it that
66+# Android does not have. (`-bootclasspath` would be the stricter way to say
67+# that, and javac refuses it for a release this recent.)
68+javac --release 17 \
69+ --class-path "$ANDROID_HOME/platforms/android-36/android.jar" \
70+ -d "$JAVA_BUILD/classes" \
71+ "$ROOT/android/java/uk/nandi/frq/FrqActivity.java"
72+"$TOOLS/d8" --min-api $API --output "$JAVA_BUILD" \
73+ $(find "$JAVA_BUILD/classes" -name '*.class')
74+
55 rm -rf "$STAGE"75 rm -rf "$STAGE"
56 mkdir -p "$STAGE/lib/arm64-v8a"76 mkdir -p "$STAGE/lib/arm64-v8a"
77+cp "$JAVA_BUILD/classes.dex" "$STAGE/classes.dex"
57 cp "$VIDYA_SO" "$STAGE/lib/arm64-v8a/libvidya.so"78 cp "$VIDYA_SO" "$STAGE/lib/arm64-v8a/libvidya.so"
58 79
59 "$NDK_BIN/aarch64-linux-android$API-clang" \80 "$NDK_BIN/aarch64-linux-android$API-clang" \
@@ -86,6 +107,8 @@ rm -f "$UNALIGNED" "$ALIGNED" "$APK"
86 # Stored, not deflated: the loader maps these straight out of the APK.107 # Stored, not deflated: the loader maps these straight out of the APK.
87 (cd "$STAGE" && zip -q -0 "$UNALIGNED" \108 (cd "$STAGE" && zip -q -0 "$UNALIGNED" \
88 lib/arm64-v8a/libvidya.so lib/arm64-v8a/libjoltapp.so)109 lib/arm64-v8a/libvidya.so lib/arm64-v8a/libjoltapp.so)
110+# The dex is read by the runtime rather than mapped, so it may as well deflate.
111+(cd "$STAGE" && zip -q "$UNALIGNED" classes.dex)
89 "$TOOLS/zipalign" -f -p 4 "$UNALIGNED" "$ALIGNED"112 "$TOOLS/zipalign" -f -p 4 "$UNALIGNED" "$ALIGNED"
90 113
91 KEYSTORE="$HOME/.android/debug.keystore"114 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/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
@@ -664,11 +664,67 @@
664664 (let [up (str/join "/" (butlast (str/split (str dir) #"/")))]
665665 (when (and (seq up) (not= up dir) (readable-dir? up)) up)))
666666
667+;; ------------------------------------------- the platform's own chooser
668+
669+;; Polling, because a chooser is another app's screen: it takes the reader away
670+;; and gives nothing back through a handler here. `choosing` is what the poll
671+;; runs on, and the count is what ends it — a reader who backs out without
672+;; choosing tells us nothing at all, so the alternative is a poll that outlives
673+;; the app's interest in the answer.
674+(defonce ^:private choosing (atom nil))
675+
676+(def ^:private choose-poll-ms 300)
677+
678+(def ^:private choose-poll-limit
679+ "Five minutes of asking. Long enough for someone who wandered off mid-choice,
680+ short enough that a cancelled chooser is not still being polled for at
681+ bedtime."
682+ 1000)
683+
684+(defn- take-chosen!
685+ "Attach the picture the chooser has written, if it has written one yet."
686+ []
687+ (let [path (paste-path)]
688+ (host/mkdirs! (str (media/cache-dir) "/outgoing"))
689+ (when (vidya/picked-image! path)
690+ (reset! choosing nil)
691+ (attach! path "picture.png")
692+ true)))
693+
694+(defn- poll-chosen! []
695+ (when-let [left @choosing]
696+ (when-not (take-chosen!)
697+ (if (pos? left)
698+ (do (reset! choosing (dec left))
699+ (vidya/after! choose-poll-ms poll-chosen!))
700+ (reset! choosing nil)))))
701+
702+(defn choose-image!
703+ "Open the platform's own picture chooser, where there is one; true when it
704+ opened.
705+
706+ Preferred to browsing on a phone, and not only for the taste of it: what the
707+ chooser hands back is a grant for the one picture the reader chose, so the
708+ app needs no permission over their pictures at all — and without such a
709+ permission, browsing finds almost nothing to show. False where there is no
710+ chooser, which is every desktop, and there browsing is the answer."
711+ []
712+ (when (vidya/pick-image!)
713+ (reset! error nil)
714+ (reset! choosing choose-poll-limit)
715+ (vidya/after! choose-poll-ms poll-chosen!)
716+ true))
717+
667718 (defn open-image-picker!
668- "Open the picker, on the first place there is to look."
719+ "Ask for a picture, whichever way this platform has of choosing one.
720+
721+ The platform's own chooser where there is one — it needs no permission and
722+ knows where the reader's pictures actually are — and otherwise this app's
723+ own browsing screen, which is what a desktop gets."
669724 []
670- (reset! error nil)
671- (reset! image-picker (or (first (picker-roots)) "/")))
725+ (when-not (choose-image!)
726+ (reset! error nil)
727+ (reset! image-picker (or (first (picker-roots)) "/"))))
672728
673729 (defn close-image-picker! [] (reset! image-picker nil))
674730
@@ -664,11 +664,67 @@
664 (let [up (str/join "/" (butlast (str/split (str dir) #"/")))]664 (let [up (str/join "/" (butlast (str/split (str dir) #"/")))]
665 (when (and (seq up) (not= up dir) (readable-dir? up)) up)))665 (when (and (seq up) (not= up dir) (readable-dir? up)) up)))
666 666
667+;; ------------------------------------------- the platform's own chooser
668+
669+;; Polling, because a chooser is another app's screen: it takes the reader away
670+;; and gives nothing back through a handler here. `choosing` is what the poll
671+;; runs on, and the count is what ends it — a reader who backs out without
672+;; choosing tells us nothing at all, so the alternative is a poll that outlives
673+;; the app's interest in the answer.
674+(defonce ^:private choosing (atom nil))
675+
676+(def ^:private choose-poll-ms 300)
677+
678+(def ^:private choose-poll-limit
679+ "Five minutes of asking. Long enough for someone who wandered off mid-choice,
680+ short enough that a cancelled chooser is not still being polled for at
681+ bedtime."
682+ 1000)
683+
684+(defn- take-chosen!
685+ "Attach the picture the chooser has written, if it has written one yet."
686+ []
687+ (let [path (paste-path)]
688+ (host/mkdirs! (str (media/cache-dir) "/outgoing"))
689+ (when (vidya/picked-image! path)
690+ (reset! choosing nil)
691+ (attach! path "picture.png")
692+ true)))
693+
694+(defn- poll-chosen! []
695+ (when-let [left @choosing]
696+ (when-not (take-chosen!)
697+ (if (pos? left)
698+ (do (reset! choosing (dec left))
699+ (vidya/after! choose-poll-ms poll-chosen!))
700+ (reset! choosing nil)))))
701+
702+(defn choose-image!
703+ "Open the platform's own picture chooser, where there is one; true when it
704+ opened.
705+
706+ Preferred to browsing on a phone, and not only for the taste of it: what the
707+ chooser hands back is a grant for the one picture the reader chose, so the
708+ app needs no permission over their pictures at all — and without such a
709+ permission, browsing finds almost nothing to show. False where there is no
710+ chooser, which is every desktop, and there browsing is the answer."
711+ []
712+ (when (vidya/pick-image!)
713+ (reset! error nil)
714+ (reset! choosing choose-poll-limit)
715+ (vidya/after! choose-poll-ms poll-chosen!)
716+ true))
717+
667 (defn open-image-picker!718 (defn open-image-picker!
668- "Open the picker, on the first place there is to look."719+ "Ask for a picture, whichever way this platform has of choosing one.
720+
721+ The platform's own chooser where there is one — it needs no permission and
722+ knows where the reader's pictures actually are — and otherwise this app's
723+ own browsing screen, which is what a desktop gets."
669 []724 []
670- (reset! error nil)725+ (when-not (choose-image!)
671- (reset! image-picker (or (first (picker-roots)) "/")))726+ (reset! error nil)
727+ (reset! image-picker (or (first (picker-roots)) "/"))))
672 728
673 (defn close-image-picker! [] (reset! image-picker nil))729 (defn close-image-picker! [] (reset! image-picker nil))
674 730