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>
7b6e915 parent: cab7487 modified
android/AndroidManifest.xml +38 -2 | @@ -2,6 +2,18 @@ | ||
| 2 | 2 | <manifest xmlns:android="http://schemas.android.com/apk/res/android" |
| 3 | 3 | package="uk.nandi.frq"> |
| 4 | 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 | 17 | <uses-feature android:glEsVersion="0x00030000" android:required="true" /> |
| 6 | 18 | |
| 7 | 19 | <!-- |
| @@ -10,9 +22,15 @@ | ||
| 10 | 22 | vidya to inset by — the status bar clock sits on top of the app and the |
| 11 | 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 | 31 | <application |
| 14 | 32 | android:allowBackup="false" |
| 15 | - android:hasCode="false" | |
| 33 | + android:debuggable="true" | |
| 16 | 34 | android:label="frq" |
| 17 | 35 | android:theme="@android:style/Theme.Material.NoActionBar"> |
| 18 | 36 | <!-- |
| @@ -20,9 +38,15 @@ | ||
| 20 | 38 | the soft keyboard, so vidya has no IME inset to reserve and the compose |
| 21 | 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 | 46 | <activity |
| 24 | - android:name="android.app.NativeActivity" | |
| 47 | + android:name=".FrqActivity" | |
| 25 | 48 | android:configChanges="orientation|keyboardHidden|screenSize" |
| 49 | + android:launchMode="singleTask" | |
| 26 | 50 | android:windowSoftInputMode="adjustResize" |
| 27 | 51 | android:exported="true" |
| 28 | 52 | android:screenOrientation="portrait"> |
| @@ -39,6 +63,18 @@ | ||
| 39 | 63 | <action android:name="android.intent.action.MAIN" /> |
| 40 | 64 | <category android:name="android.intent.category.LAUNCHER" /> |
| 41 | 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 | 78 | </activity> |
| 43 | 79 | </application> |
| 44 | 80 | </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 the | 22 | 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 | <application | 31 | <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 compose | 38 | 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 | <activity | 46 | <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 @@ | ||
| 6 | 6 | # glue, so it owns the event loop) |
| 7 | 7 | # libjoltapp.so vidya's android/jolt_main.c plus frq's Jolt boot image, |
| 8 | 8 | # 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 | 13 | # Neither half is built here beyond that last link: the UI library comes from |
| 11 | 14 | # 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 | 27 | ADB="${ADB:-$ANDROID_HOME/platform-tools/adb}" |
| 25 | 28 | NDK_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" |
| 26 | 29 | PACKAGE="uk.nandi.frq" |
| 27 | -ACTIVITY="$PACKAGE/android.app.NativeActivity" | |
| 30 | +ACTIVITY="$PACKAGE/.FrqActivity" | |
| 28 | 31 | API=28 |
| 29 | 32 | |
| 30 | 33 | for path in \ |
| 31 | 34 | "$NDK_BIN/aarch64-linux-android$API-clang" \ |
| 32 | 35 | "$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 | |
| 34 | 37 | [[ -e "$path" ]] || { echo "missing Android tool: $path" >&2; exit 1; } |
| 35 | 38 | done |
| 36 | 39 | |
| @@ -52,8 +55,26 @@ VIDYA_SO="$VIDYA/build/android/arm64-v8a/libvidya.so" | ||
| 52 | 55 | 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 | 75 | rm -rf "$STAGE" |
| 56 | 76 | mkdir -p "$STAGE/lib/arm64-v8a" |
| 77 | +cp "$JAVA_BUILD/classes.dex" "$STAGE/classes.dex" | |
| 57 | 78 | cp "$VIDYA_SO" "$STAGE/lib/arm64-v8a/libvidya.so" |
| 58 | 79 | |
| 59 | 80 | "$NDK_BIN/aarch64-linux-android$API-clang" \ |
| @@ -86,6 +107,8 @@ rm -f "$UNALIGNED" "$ALIGNED" "$APK" | ||
| 86 | 107 | # Stored, not deflated: the loader maps these straight out of the APK. |
| 87 | 108 | (cd "$STAGE" && zip -q -0 "$UNALIGNED" \ |
| 88 | 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 | 112 | "$TOOLS/zipalign" -f -p 4 "$UNALIGNED" "$ALIGNED" |
| 90 | 113 | |
| 91 | 114 | 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 above | 8 | # 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 from | 13 | # 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. Both | 14 | # 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=28 | 31 | 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"; do | 36 | + "$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 | done | 38 | done |
| 36 | 39 | ||
| @@ -52,8 +55,26 @@ VIDYA_SO="$VIDYA/build/android/arm64-v8a/libvidya.so" | |||
| 52 | jolt.boot jolt_boot.o | 55 | 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 @@ | ||
| 51 | 51 | |
| 52 | 52 | ;; ------------------------------------------------------------------ capture |
| 53 | 53 | |
| 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] | |
| 55 | 64 | (str "<!doctype html><meta charset=utf-8><title>frq</title>" |
| 56 | 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 | 72 | "var h=location.hash.replace(/^#/,'');" |
| 59 | 73 | "var p=new URLSearchParams(h).get('oauth')||h.replace(/^oauth=/,'');" |
| 60 | 74 | "if(!p){document.getElementById('m').textContent='No sign-in payload in this URL.';}" |
| 61 | 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 | 84 | ".catch(function(e){document.getElementById('m').textContent='Handoff failed: '+e;});}" |
| 64 | 85 | "</script></body>")) |
| 65 | 86 | |
| @@ -131,7 +152,8 @@ | ||
| 131 | 152 | ;; A POST that carried nothing usable is not the end of the |
| 132 | 153 | ;; wait — keep serving, the real handoff may still arrive. |
| 133 | 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 | 157 | (socket/c-close fd) |
| 136 | 158 | (recur))))))) |
| 137 | 159 | (finally (socket/c-close server))))) |
| @@ -51,15 +51,36 @@ | |||
| 51 | 51 | ||
| 52 | ;; ------------------------------------------------------------------ capture | 52 | ;; ------------------------------------------------------------------ capture |
| 53 | 53 | ||
| 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] | ||
| 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 the | 152 | ;; 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 @@ | ||
| 1 | 1 | (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] | |
| 4 | 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 | 12 | (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." | |
| 9 | 17 | [url] |
| 10 | 18 | (try |
| 11 | - (zero? (host/sh (str "xdg-open '" (str/replace (or url "") "'" "%27") "' >/dev/null 2>&1 &"))) | |
| 19 | + (vidya/open-url! (or url "")) | |
| 12 | 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")) | |
| @@ -1,12 +1,28 @@ | |||
| 1 | (ns frq.platform | 1 | (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-op | 13 | + "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 | (try | 18 | (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 @@ | ||
| 664 | 664 | (let [up (str/join "/" (butlast (str/split (str dir) #"/")))] |
| 665 | 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 | 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) | |
| 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)) "/")))) | |
| 672 | 728 | |
| 673 | 729 | (defn close-image-picker! [] (reset! image-picker nil)) |
| 674 | 730 | |
| @@ -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 | ||