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 +36 -3 | @@ -6,6 +6,12 @@ | ||
| 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 | |
| 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 | 16 | # Neither half is built here beyond that last link: the UI library comes from |
| 11 | 17 | # 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 | 23 | ANDROID_HOME="${ANDROID_HOME:-$HOME/.local/share/android-sdk}" |
| 18 | 24 | ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$HOME/.local/share/android-ndk-r29}" |
| 19 | 25 | CHEZ_ANDROID="${CHEZ_ANDROID:-$HOME/.cache/vidya-chez-android}" |
| 26 | +OPENSSL_ANDROID="${OPENSSL_ANDROID:-$HOME/.cache/frq-openssl-android/lib}" | |
| 20 | 27 | BUILD="$ROOT/android/build" |
| 21 | 28 | JOLT_BUILD="$BUILD/jolt" |
| 22 | 29 | STAGE="$BUILD/stage" |
| @@ -24,13 +31,14 @@ TOOLS="$ANDROID_HOME/build-tools/36.0.0" | ||
| 24 | 31 | ADB="${ADB:-$ANDROID_HOME/platform-tools/adb}" |
| 25 | 32 | NDK_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" |
| 26 | 33 | PACKAGE="uk.nandi.frq" |
| 27 | -ACTIVITY="$PACKAGE/android.app.NativeActivity" | |
| 34 | +ACTIVITY="$PACKAGE/.FrqActivity" | |
| 28 | 35 | API=28 |
| 29 | 36 | |
| 30 | 37 | for path in \ |
| 31 | 38 | "$NDK_BIN/aarch64-linux-android$API-clang" \ |
| 32 | 39 | "$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 | |
| 34 | 42 | [[ -e "$path" ]] || { echo "missing Android tool: $path" >&2; exit 1; } |
| 35 | 43 | done |
| 36 | 44 | |
| @@ -52,9 +60,31 @@ VIDYA_SO="$VIDYA/build/android/arm64-v8a/libvidya.so" | ||
| 52 | 60 | 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 | 80 | rm -rf "$STAGE" |
| 56 | 81 | mkdir -p "$STAGE/lib/arm64-v8a" |
| 82 | +cp "$JAVA_BUILD/classes.dex" "$STAGE/classes.dex" | |
| 57 | 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 | 89 | "$NDK_BIN/aarch64-linux-android$API-clang" \ |
| 60 | 90 | -shared -fPIC -O2 \ |
| @@ -85,7 +115,10 @@ rm -f "$UNALIGNED" "$ALIGNED" "$APK" | ||
| 85 | 115 | --version-name 0.1.0 |
| 86 | 116 | # Stored, not deflated: the loader maps these straight out of the APK. |
| 87 | 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 | 122 | "$TOOLS/zipalign" -f -p 4 "$UNALIGNED" "$ALIGNED" |
| 90 | 123 | |
| 91 | 124 | 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 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 | ||
| 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 from | 16 | # 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 | 17 | # 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=28 | 35 | 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"; do | 40 | + "$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 | done | 43 | done |
| 36 | 44 | ||
| @@ -52,9 +60,31 @@ VIDYA_SO="$VIDYA/build/android/arm64-v8a/libvidya.so" | |||
| 52 | jolt.boot jolt_boot.o | 60 | 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.0 | 115 | --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 @@ | ||
| 723 | 723 | [:vbox {:key :call} |
| 724 | 724 | (when (and name |
| 725 | 725 | (str/starts-with? name "#") |
| 726 | + (av/available?) | |
| 726 | 727 | (not (av/call-in name)) |
| 727 | 728 | (not (av/in-call?))) |
| 728 | 729 | [:button {:label "Call" :on-click #(s/start-call! name)}])]] |
| @@ -723,6 +723,7 @@ | |||
| 723 | [:vbox {:key :call} | 723 | [:vbox {:key :call} |
| 724 | (when (and name | 724 | (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 @@ | ||
| 73 | 73 | (def ^:private status-ended 2) |
| 74 | 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 | 91 | (defn- flag [b] (if b 1 0)) |
| 77 | 92 | (defn- pref [s] (or s "")) |
| 78 | 93 | |
| @@ -335,7 +350,8 @@ | ||
| 335 | 350 | alone rather than re-dialled." |
| 336 | 351 | [server] |
| 337 | 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 | 355 | (not (contains? #{:dialling :live} media)) |
| 340 | 356 | (can-dial? server token)) |
| 341 | 357 | (start-media! server)))) |
| @@ -541,17 +557,21 @@ | ||
| 541 | 557 | Worth doing unconditionally: it says nothing at all without a `RUST_LOG`, |
| 542 | 558 | and when a call misbehaves it is the only thing that knows why — MoQ |
| 543 | 559 | 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 | 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 | 570 | Sixteen milliseconds rather than a longer gap because this is where video |
| 552 | 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 | 576 | (defn tiles |
| 557 | 577 | "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 — MoQ | 558 | 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 of | 559 | 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 video | 570 | 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 tiles | 576 | (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 @@ | ||
| 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 | @@ -759,11 +759,67 @@ | ||
| 759 | 759 | (let [up (str/join "/" (butlast (str/split (str dir) #"/")))] |
| 760 | 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 | 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) | |
| 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)) "/")))) | |
| 767 | 823 | |
| 768 | 824 | (defn close-image-picker! [] (reset! image-picker nil)) |
| 769 | 825 | |
| @@ -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 | ||