diff --git a/.github/mobile-install.md b/.github/mobile-install.md new file mode 100644 index 00000000..1a220848 --- /dev/null +++ b/.github/mobile-install.md @@ -0,0 +1,6 @@ +## Phone app + +Watch and drive this computer's panes and agents from your phone. On the computer, open **Settings → Mobile**, turn on **Allow phone access** and click **Show code**, then pair from the app by scanning the code or pasting it. + +- **iPhone**: join the beta at https://testflight.apple.com/join/gFcPbPVR. The link asks you to install TestFlight from the App Store first, if you don't have it. +- **Android** (arm64): on the phone, download `tty7--android-arm64.apk` from the newest [`mobile-v*` release](https://github.com/l0ng-ai/tty7/releases?q=mobile-v&expanded=true) and open it. Android asks once to allow installing apps from your browser or file manager. A later APK installs over it and keeps your paired machines. diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml new file mode 100644 index 00000000..0ab4454d --- /dev/null +++ b/.github/workflows/mobile.yml @@ -0,0 +1,140 @@ +name: Mobile + +# The phone app's Android build: a signed APK people install by hand. A +# `mobile-v` tag builds it at that version and attaches it to a draft +# release of the same name; a manual run builds it at the version in +# tauri.conf.json and keeps it as a workflow artifact only. +# +# The mobile app is versioned apart from the desktop's `v*` tags. Android +# installs one build over another only when its versionCode is higher, and +# Tauri derives it from the version (major * 1000000 + minor * 1000 + patch), +# so each tag must be higher than the last. + +on: + push: + tags: ["mobile-v*"] + workflow_dispatch: + +permissions: + contents: write + +env: + # The release key's certificate. A build signed with any other key could not + # be installed over the ones people already have, so it is refused here. + CERT_SHA256: c54b8b466e7ac45bac2c9d79e44ec3e6ab8fef1d00818adafa11bb747163d75f + NDK_VERSION: 28.2.13676358 + +jobs: + android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Version + run: | + set -euo pipefail + if [[ "$GITHUB_REF" == refs/tags/mobile-v* ]]; then + VERSION="${GITHUB_REF_NAME#mobile-v}" + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::tag must be mobile-v.., got $GITHUB_REF_NAME" + exit 1 + fi + else + VERSION=$(jq -r .version mobile/src-tauri/tauri.conf.json) + fi + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: mobile/package-lock.json + + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: mobile/src-tauri + + # The runner has an SDK and some NDK; the NDK is pinned so a runner image + # update cannot change the toolchain under a release. + - name: Android NDK + run: | + set -euo pipefail + yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "ndk;$NDK_VERSION" > /dev/null + echo "NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION" >> "$GITHUB_ENV" + + - name: Signing key + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + run: | + set -euo pipefail + if [ -z "$ANDROID_KEYSTORE_BASE64" ]; then + echo "::error::the ANDROID_KEYSTORE_* secrets are not set" + exit 1 + fi + echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/release.jks" + # Read by gen/android/app/build.gradle.kts; git ignores it. + { + echo "storeFile=$RUNNER_TEMP/release.jks" + echo "storePassword=$ANDROID_KEYSTORE_PASSWORD" + echo "keyAlias=$ANDROID_KEY_ALIAS" + echo "keyPassword=$ANDROID_KEYSTORE_PASSWORD" + } > mobile/src-tauri/gen/android/keystore.properties + + # arm64 only: every phone of the last several years, with one copy of + # the native library instead of four. + - name: Build + working-directory: mobile + run: | + set -euo pipefail + npm ci + npx tauri android build --apk --target aarch64 --config "{\"version\":\"$VERSION\"}" + + - name: Check and name the APK + run: | + set -euo pipefail + APK=mobile/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk + APKSIGNER=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1)/apksigner + CERT=$("$APKSIGNER" verify --print-certs "$APK" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p') + if [ "$CERT" != "$CERT_SHA256" ]; then + echo "::error::APK is signed with $CERT, not the release key" + exit 1 + fi + mkdir dist + cp "$APK" "dist/tty7-$VERSION-android-arm64.apk" + (cd dist && sha256sum *.apk > SHA256SUMS) + + - uses: actions/upload-artifact@v7 + with: + name: tty7-android-${{ env.VERSION }} + path: dist/* + + # A draft, published by hand as the desktop's are. Never marked latest: + # the desktop updater reads /releases/latest and would take this for a + # desktop release. + - name: Draft release + if: startsWith(github.ref, 'refs/tags/mobile-v') + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + EXISTING=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 100 \ + --json tagName -q '.[].tagName') + if grep -Fxq "$GITHUB_REF_NAME" <<<"$EXISTING"; then + echo "release $GITHUB_REF_NAME already exists; reusing it" + else + gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \ + --draft --latest=false --title "tty7 mobile $VERSION" \ + --notes-file .github/mobile-install.md + fi + gh release upload "$GITHUB_REF_NAME" dist/* --clobber --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index e3243a9b..341d973c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -546,7 +546,9 @@ jobs: git push -f origin "$GITHUB_SHA:refs/tags/nightly" TITLE="Nightly $VERSION" - NOTES="Automated nightly build of \`main\` @ ${GITHUB_SHA::7} ($(date -u +%F)). Rolling prerelease — assets are replaced every night; for the latest stable release see https://github.com/${GITHUB_REPOSITORY}/releases/latest." + NOTES="Automated nightly build of \`main\` @ ${GITHUB_SHA::7} ($(date -u +%F)). Rolling prerelease — assets are replaced every night; for the latest stable release see https://github.com/${GITHUB_REPOSITORY}/releases/latest. + + $(cat .github/mobile-install.md)" if gh release view nightly >/dev/null 2>&1; then gh release edit nightly --prerelease --title "$TITLE" --notes "$NOTES" diff --git a/mobile/README.md b/mobile/README.md index 8ffa3e57..76614912 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -83,13 +83,35 @@ last. `--build-number 7` picks it. ### Android -Needs Android Studio's SDK and NDK, with `ANDROID_HOME` and `NDK_HOME` set. +Needs the Android SDK and NDK, with `ANDROID_HOME` and `NDK_HOME` set, and a JDK 17 to 21 +as `JAVA_HOME`. ```sh -rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android +rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android cd mobile -npm run tauri android init -npm run tauri android dev +npm run tauri android dev # emulator or a connected phone +npm run tauri android build -- --debug --apk --target aarch64 # an installable .apk +``` + +`src-tauri/gen/android` is kept in the repo, unlike `gen/apple`: its `MainActivity` hands the +keyboard's height to the page, which the WebView does not report edge to edge. Don't re-run +`android init` over it. + +To release an APK, push a `mobile-v` tag, higher than the last. `.github/workflows/mobile.yml` +builds it at that version, signs it with the release key and attaches it to a draft +release, which is never marked latest, so the desktop updater doesn't see it. People +download the APK on the phone and open it. A later one installs over it only if it's +signed with the same key and its version is higher. + +The release key is in the `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD` and +`ANDROID_KEY_ALIAS` secrets, with a copy kept outside GitHub. A local release build signs +with it when `src-tauri/gen/android/keystore.properties` (ignored by git) names it: + +```properties +storeFile=/path/to/tty7-release.jks +storePassword=… +keyAlias=tty7 +keyPassword=… ``` ### Without a phone diff --git a/mobile/src-tauri/Cargo.lock b/mobile/src-tauri/Cargo.lock index 21e441b8..f15aaea7 100644 --- a/mobile/src-tauri/Cargo.lock +++ b/mobile/src-tauri/Cargo.lock @@ -5809,6 +5809,7 @@ version = "0.1.0" dependencies = [ "anyhow", "iroh", + "jni 0.21.1", "objc2", "rustls", "serde", diff --git a/mobile/src-tauri/Cargo.toml b/mobile/src-tauri/Cargo.toml index 1ed07a64..e47dc3d9 100644 --- a/mobile/src-tauri/Cargo.toml +++ b/mobile/src-tauri/Cargo.toml @@ -38,6 +38,16 @@ tauri-plugin-barcode-scanner = "2" # Face ID / passcode before the app shows anything, when turned on. tauri-plugin-biometric = "2" +# Reading the system bars' size and leaving for the background (`insets`, +# `to_background`), which the Tauri API does not reach. +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21" + # Setting one WKWebView property the Tauri API does not reach (`edge_to_edge`). [target.'cfg(target_os = "ios")'.dependencies] objc2 = "0.6" + +# The Android library ships as built: stripping takes it from 30 MB to 20, and +# the APK from 32 to 23. iOS strips at export either way. +[profile.release] +strip = true diff --git a/mobile/src-tauri/gen/android/.editorconfig b/mobile/src-tauri/gen/android/.editorconfig new file mode 100644 index 00000000..ebe51d3b --- /dev/null +++ b/mobile/src-tauri/gen/android/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/mobile/src-tauri/gen/android/.gitignore b/mobile/src-tauri/gen/android/.gitignore new file mode 100644 index 00000000..1c636c39 --- /dev/null +++ b/mobile/src-tauri/gen/android/.gitignore @@ -0,0 +1,20 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +build +/captures +.externalNativeBuild +.cxx +local.properties +key.properties +keystore.properties + +/.tauri +/tauri.settings.gradle \ No newline at end of file diff --git a/mobile/src-tauri/gen/android/app/.gitignore b/mobile/src-tauri/gen/android/app/.gitignore new file mode 100644 index 00000000..6c4d56b4 --- /dev/null +++ b/mobile/src-tauri/gen/android/app/.gitignore @@ -0,0 +1,6 @@ +/src/main/**/generated +/src/main/jniLibs/**/*.so +/src/main/assets/tauri.conf.json +/tauri.build.gradle.kts +/proguard-tauri.pro +/tauri.properties \ No newline at end of file diff --git a/mobile/src-tauri/gen/android/app/build.gradle.kts b/mobile/src-tauri/gen/android/app/build.gradle.kts new file mode 100644 index 00000000..10c4b239 --- /dev/null +++ b/mobile/src-tauri/gen/android/app/build.gradle.kts @@ -0,0 +1,93 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("rust") +} + +val tauriProperties = Properties().apply { + val propFile = file("tauri.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + +// Release signing, from keystore.properties beside this project (ignored by +// git): `storeFile`, `storePassword`, `keyAlias`, `keyPassword`. CI writes it +// from secrets (.github/workflows/mobile.yml). Without it a release build +// comes out unsigned, and a debug build uses the machine's debug key. +val keystoreProperties = Properties().apply { + val propFile = rootProject.file("keystore.properties") + if (propFile.exists()) { + propFile.inputStream().use { load(it) } + } +} + +android { + compileSdk = 36 + namespace = "dev.tty7.mobile" + defaultConfig { + manifestPlaceholders["usesCleartextTraffic"] = "false" + applicationId = "dev.tty7.mobile" + minSdk = 24 + targetSdk = 36 + versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt() + versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0") + } + if (keystoreProperties.containsKey("storeFile")) { + signingConfigs { + create("release") { + storeFile = rootProject.file(keystoreProperties.getProperty("storeFile")) + storePassword = keystoreProperties.getProperty("storePassword") + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + } + } + } + buildTypes { + getByName("debug") { + manifestPlaceholders["usesCleartextTraffic"] = "true" + isDebuggable = true + isJniDebuggable = true + isMinifyEnabled = false + packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so") + jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so") + jniLibs.keepDebugSymbols.add("*/x86/*.so") + jniLibs.keepDebugSymbols.add("*/x86_64/*.so") + } + } + getByName("release") { + signingConfigs.findByName("release")?.let { signingConfig = it } + isMinifyEnabled = true + proguardFiles( + *fileTree(".") { include("**/*.pro") } + .plus(getDefaultProguardFile("proguard-android-optimize.txt")) + .toList().toTypedArray() + ) + } + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + buildConfig = true + } +} + +rust { + rootDirRel = "../../../" +} + +dependencies { + implementation("androidx.webkit:webkit:1.14.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("androidx.activity:activity-ktx:1.10.1") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.lifecycle:lifecycle-process:2.10.0") + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.4") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") +} + +apply(from = "tauri.build.gradle.kts") \ No newline at end of file diff --git a/mobile/src-tauri/gen/android/app/proguard-rules.pro b/mobile/src-tauri/gen/android/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/mobile/src-tauri/gen/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..12e2393e --- /dev/null +++ b/mobile/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src-tauri/gen/android/app/src/main/java/dev/tty7/mobile/MainActivity.kt b/mobile/src-tauri/gen/android/app/src/main/java/dev/tty7/mobile/MainActivity.kt new file mode 100644 index 00000000..7d8ab395 --- /dev/null +++ b/mobile/src-tauri/gen/android/app/src/main/java/dev/tty7/mobile/MainActivity.kt @@ -0,0 +1,30 @@ +package dev.tty7.mobile + +import android.os.Bundle +import android.webkit.WebView +import androidx.activity.enableEdgeToEdge +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat + +class MainActivity : TauriActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + } + + // Edge to edge, the WebView is never resized for the keyboard, and it does + // not shrink the page's visual viewport either: the page could not tell the + // keyboard is up, and it would cover the message box. Its height is handed + // to the page instead (main.ts `android-keyboard`), which then lays out as + // it does on iOS. The WebView still sees the insets itself. + override fun onWebViewCreate(webView: WebView) { + ViewCompat.setOnApplyWindowInsetsListener(webView) { view, insets -> + val keyboard = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom / view.resources.displayMetrics.density + webView.evaluateJavascript( + "window.dispatchEvent(new CustomEvent('android-keyboard', { detail: $keyboard }))", + null, + ) + ViewCompat.onApplyWindowInsets(view, insets) + } + } +} diff --git a/mobile/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/mobile/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/mobile/src-tauri/gen/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/mobile/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml b/mobile/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/mobile/src-tauri/gen/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml b/mobile/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..4fc24441 --- /dev/null +++ b/mobile/src-tauri/gen/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..28f1aa11 Binary files /dev/null and b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..85d0c88a Binary files /dev/null and b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..28f1aa11 Binary files /dev/null and b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..73e48dbf Binary files /dev/null and b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..13dd2147 Binary files /dev/null and b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..73e48dbf Binary files /dev/null and b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..1d98044f Binary files /dev/null and b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..a888b336 Binary files /dev/null and b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..1d98044f Binary files /dev/null and b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..08183246 Binary files /dev/null and b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..a2a838e7 Binary files /dev/null and b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..08183246 Binary files /dev/null and b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..b18bceb6 Binary files /dev/null and b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..3f8a57f3 Binary files /dev/null and b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..b18bceb6 Binary files /dev/null and b/mobile/src-tauri/gen/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/mobile/src-tauri/gen/android/app/src/main/res/values-night/themes.xml b/mobile/src-tauri/gen/android/app/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..b9e5def5 --- /dev/null +++ b/mobile/src-tauri/gen/android/app/src/main/res/values-night/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/mobile/src-tauri/gen/android/app/src/main/res/values/colors.xml b/mobile/src-tauri/gen/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..f8c6127d --- /dev/null +++ b/mobile/src-tauri/gen/android/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/mobile/src-tauri/gen/android/app/src/main/res/values/strings.xml b/mobile/src-tauri/gen/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..ad79ed32 --- /dev/null +++ b/mobile/src-tauri/gen/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + "tty7" + "tty7" + \ No newline at end of file diff --git a/mobile/src-tauri/gen/android/app/src/main/res/values/themes.xml b/mobile/src-tauri/gen/android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..b9e5def5 --- /dev/null +++ b/mobile/src-tauri/gen/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/mobile/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml b/mobile/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..782d63b9 --- /dev/null +++ b/mobile/src-tauri/gen/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile/src-tauri/gen/android/build.gradle.kts b/mobile/src-tauri/gen/android/build.gradle.kts new file mode 100644 index 00000000..607240bc --- /dev/null +++ b/mobile/src-tauri/gen/android/build.gradle.kts @@ -0,0 +1,22 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register("clean").configure { + delete("build") +} + diff --git a/mobile/src-tauri/gen/android/buildSrc/build.gradle.kts b/mobile/src-tauri/gen/android/buildSrc/build.gradle.kts new file mode 100644 index 00000000..5c55bba7 --- /dev/null +++ b/mobile/src-tauri/gen/android/buildSrc/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + `kotlin-dsl` +} + +gradlePlugin { + plugins { + create("pluginsForCoolKids") { + id = "rust" + implementationClass = "RustPlugin" + } + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + compileOnly(gradleApi()) + implementation("com.android.tools.build:gradle:8.11.0") +} + diff --git a/mobile/src-tauri/gen/android/buildSrc/src/main/java/dev/tty7/mobile/kotlin/BuildTask.kt b/mobile/src-tauri/gen/android/buildSrc/src/main/java/dev/tty7/mobile/kotlin/BuildTask.kt new file mode 100644 index 00000000..a3de1256 --- /dev/null +++ b/mobile/src-tauri/gen/android/buildSrc/src/main/java/dev/tty7/mobile/kotlin/BuildTask.kt @@ -0,0 +1,68 @@ +import java.io.File +import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.logging.LogLevel +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction + +open class BuildTask : DefaultTask() { + @Input + var rootDirRel: String? = null + @Input + var target: String? = null + @Input + var release: Boolean? = null + + @TaskAction + fun assemble() { + val executable = """npm"""; + try { + runTauriCli(executable) + } catch (e: Exception) { + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + // Try different Windows-specific extensions + val fallbacks = listOf( + "$executable.exe", + "$executable.cmd", + "$executable.bat", + ) + + var lastException: Exception = e + for (fallback in fallbacks) { + try { + runTauriCli(fallback) + return + } catch (fallbackException: Exception) { + lastException = fallbackException + } + } + throw lastException + } else { + throw e; + } + } + } + + fun runTauriCli(executable: String) { + val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null") + val target = target ?: throw GradleException("target cannot be null") + val release = release ?: throw GradleException("release cannot be null") + val args = listOf("run", "--", "tauri", "android", "android-studio-script"); + + project.exec { + workingDir(File(project.projectDir, rootDirRel)) + executable(executable) + args(args) + if (project.logger.isEnabled(LogLevel.DEBUG)) { + args("-vv") + } else if (project.logger.isEnabled(LogLevel.INFO)) { + args("-v") + } + if (release) { + args("--release") + } + args(listOf("--target", target)) + }.assertNormalExitValue() + } +} \ No newline at end of file diff --git a/mobile/src-tauri/gen/android/buildSrc/src/main/java/dev/tty7/mobile/kotlin/RustPlugin.kt b/mobile/src-tauri/gen/android/buildSrc/src/main/java/dev/tty7/mobile/kotlin/RustPlugin.kt new file mode 100644 index 00000000..4aa7fcaf --- /dev/null +++ b/mobile/src-tauri/gen/android/buildSrc/src/main/java/dev/tty7/mobile/kotlin/RustPlugin.kt @@ -0,0 +1,85 @@ +import com.android.build.api.dsl.ApplicationExtension +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.get + +const val TASK_GROUP = "rust" + +open class Config { + lateinit var rootDirRel: String +} + +open class RustPlugin : Plugin { + private lateinit var config: Config + + override fun apply(project: Project) = with(project) { + config = extensions.create("rust", Config::class.java) + + val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64"); + val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList + + val defaultArchList = listOf("arm64", "arm", "x86", "x86_64"); + val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList + + val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64") + + extensions.configure { + @Suppress("UnstableApiUsage") + flavorDimensions.add("abi") + productFlavors { + create("universal") { + dimension = "abi" + ndk { + abiFilters += abiList + } + } + defaultArchList.forEachIndexed { index, arch -> + create(arch) { + dimension = "abi" + ndk { + abiFilters.add(defaultAbiList[index]) + } + } + } + } + } + + afterEvaluate { + for (profile in listOf("debug", "release")) { + val profileCapitalized = profile.replaceFirstChar { it.uppercase() } + val buildTask = tasks.maybeCreate( + "rustBuildUniversal$profileCapitalized", + DefaultTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for all targets" + } + + tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask) + + for (targetPair in targetsList.withIndex()) { + val targetName = targetPair.value + val targetArch = archList[targetPair.index] + val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() } + val targetBuildTask = project.tasks.maybeCreate( + "rustBuild$targetArchCapitalized$profileCapitalized", + BuildTask::class.java + ).apply { + group = TASK_GROUP + description = "Build dynamic library in $profile mode for $targetArch" + rootDirRel = config.rootDirRel + target = targetName + release = profile == "release" + } + + buildTask.dependsOn(targetBuildTask) + tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn( + targetBuildTask + ) + } + } + } + } +} \ No newline at end of file diff --git a/mobile/src-tauri/gen/android/gradle.properties b/mobile/src-tauri/gen/android/gradle.properties new file mode 100644 index 00000000..2a7ec695 --- /dev/null +++ b/mobile/src-tauri/gen/android/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.nonFinalResIds=false \ No newline at end of file diff --git a/mobile/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar b/mobile/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e708b1c0 Binary files /dev/null and b/mobile/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mobile/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties b/mobile/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..c5f9a53c --- /dev/null +++ b/mobile/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue May 10 19:22:52 CST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/mobile/src-tauri/gen/android/gradlew b/mobile/src-tauri/gen/android/gradlew new file mode 100755 index 00000000..4f906e0c --- /dev/null +++ b/mobile/src-tauri/gen/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/mobile/src-tauri/gen/android/gradlew.bat b/mobile/src-tauri/gen/android/gradlew.bat new file mode 100644 index 00000000..107acd32 --- /dev/null +++ b/mobile/src-tauri/gen/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mobile/src-tauri/gen/android/settings.gradle b/mobile/src-tauri/gen/android/settings.gradle new file mode 100644 index 00000000..39391166 --- /dev/null +++ b/mobile/src-tauri/gen/android/settings.gradle @@ -0,0 +1,3 @@ +include ':app' + +apply from: 'tauri.settings.gradle' diff --git a/mobile/src-tauri/src/lib.rs b/mobile/src-tauri/src/lib.rs index 158abd3b..d27efe2d 100644 --- a/mobile/src-tauri/src/lib.rs +++ b/mobile/src-tauri/src/lib.rs @@ -540,8 +540,22 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ - hosts, pair, forget, watch, unwatch, refresh, tab_new, upload, diff, pane_open, - pane_input, pane_lease, pane_close, appearance + hosts, + pair, + forget, + watch, + unwatch, + refresh, + tab_new, + upload, + diff, + pane_open, + pane_input, + pane_lease, + pane_close, + appearance, + insets, + to_background ]) .run(tauri::generate_context!()) .expect("error while running tty7"); @@ -597,3 +611,144 @@ fn edge_to_edge(window: &tauri::WebviewWindow) { } }); } + +/// The system bars and display cutout the page is drawn under, in CSS pixels. +/// Android runs the WebView edge to edge, yet WebViews before 140 report +/// `env(safe-area-inset-*)` as 0, so the page asks here. Zero elsewhere: iOS's +/// WebView reports its own. +#[derive(Serialize, Default)] +struct Insets { + top: f64, + right: f64, + bottom: f64, + left: f64, +} + +#[tauri::command] +async fn insets(window: tauri::WebviewWindow) -> Insets { + #[cfg(target_os = "android")] + { + let (tx, rx) = tokio::sync::oneshot::channel(); + let _ = window.with_webview(move |webview| { + webview.jni_handle().exec(move |env, activity, _| { + let insets = android::insets(env, activity).unwrap_or_else(|_| { + let _ = env.exception_clear(); + Insets::default() + }); + let _ = tx.send(insets); + }) + }); + rx.await.unwrap_or_default() + } + #[cfg(not(target_os = "android"))] + { + let _ = window; + Insets::default() + } +} + +/// Back from the first screen: the app goes to the background, as Android's +/// own apps do, rather than being closed. +#[tauri::command] +fn to_background(window: tauri::WebviewWindow) { + #[cfg(target_os = "android")] + let _ = window.with_webview(|webview| { + webview.jni_handle().exec(|env, activity, _| { + if env + .call_method(activity, "moveTaskToBack", "(Z)Z", &[true.into()]) + .is_err() + { + let _ = env.exception_clear(); + } + }) + }); + #[cfg(not(target_os = "android"))] + let _ = window; +} + +#[cfg(target_os = "android")] +mod android { + use jni::JNIEnv; + use jni::errors::Result; + use jni::objects::JObject; + + use super::Insets; + + pub fn insets(env: &mut JNIEnv, activity: &JObject) -> Result { + let window = env + .call_method(activity, "getWindow", "()Landroid/view/Window;", &[])? + .l()?; + let decor = env + .call_method(&window, "getDecorView", "()Landroid/view/View;", &[])? + .l()?; + let insets = env + .call_method( + &decor, + "getRootWindowInsets", + "()Landroid/view/WindowInsets;", + &[], + )? + .l()?; + // Not attached yet: nothing to avoid. + if insets.is_null() { + return Ok(Insets::default()); + } + let sdk = env + .get_static_field("android/os/Build$VERSION", "SDK_INT", "I")? + .i()?; + let [top, right, bottom, left] = if sdk >= 30 { + let types = "android/view/WindowInsets$Type"; + let bars = env + .call_static_method(types, "systemBars", "()I", &[])? + .i()?; + let cutout = env + .call_static_method(types, "displayCutout", "()I", &[])? + .i()?; + let edges = env + .call_method( + &insets, + "getInsets", + "(I)Landroid/graphics/Insets;", + &[(bars | cutout).into()], + )? + .l()?; + let mut side = |name| env.get_field(&edges, name, "I").and_then(|v| v.i()); + [side("top")?, side("right")?, side("bottom")?, side("left")?] + } else { + let mut side = |name| { + env.call_method(&insets, name, "()I", &[]) + .and_then(|v| v.i()) + }; + [ + side("getSystemWindowInsetTop")?, + side("getSystemWindowInsetRight")?, + side("getSystemWindowInsetBottom")?, + side("getSystemWindowInsetLeft")?, + ] + }; + let resources = env + .call_method( + activity, + "getResources", + "()Landroid/content/res/Resources;", + &[], + )? + .l()?; + let metrics = env + .call_method( + &resources, + "getDisplayMetrics", + "()Landroid/util/DisplayMetrics;", + &[], + )? + .l()?; + let density = f64::from(env.get_field(&metrics, "density", "F")?.f()?).max(1.0); + let css = |px: i32| f64::from(px) / density; + Ok(Insets { + top: css(top), + right: css(right), + bottom: css(bottom), + left: css(left), + }) + } +} diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 46eddd59..89bffb02 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -86,6 +86,19 @@ export const forget = (hostId: string) => invoke("forget", { hostId }); /** Sets the style of what the page does not draw: status bar, keyboard. */ export const appearance = (style: "system" | "light" | "dark") => invoke("appearance", { style }); +export interface Insets { + top: number; + right: number; + bottom: number; + left: number; +} + +/** What the system bars cover, in CSS pixels. Android only: zero elsewhere. */ +export const insets = () => invoke("insets"); + +/** Sends the app to the background, as Back from the first screen does. */ +export const toBackground = () => invoke("to_background"); + export function watch(hostId: string, onMsg: (msg: TreeMsg) => void) { const onEvent = new Channel(); onEvent.onmessage = onMsg; diff --git a/mobile/src/main.ts b/mobile/src/main.ts index fa5b4eff..279e8879 100644 --- a/mobile/src/main.ts +++ b/mobile/src/main.ts @@ -9,7 +9,7 @@ import { authenticate, checkStatus } from "@tauri-apps/plugin-biometric"; import type { ITheme } from "@xterm/xterm"; import * as scanner from "@tauri-apps/plugin-barcode-scanner"; -import { getVersion } from "@tauri-apps/api/app"; +import { getVersion, onBackButtonPress } from "@tauri-apps/api/app"; import * as api from "./api"; import { MAX_UPLOAD } from "./api"; @@ -29,17 +29,21 @@ import logoUrl from "./assets/logo.svg?url"; const app = document.getElementById("app")!; +const android = /Android/.test(navigator.userAgent); + // The keyboard. The WebView runs edge to edge and is never resized for it // (lib.rs `edge_to_edge`): the keyboard simply covers the bottom of the page. // What is left is the visual viewport, so the app is sized to that, and the // dock and the message box sit on top of the keyboard. Screens that lay out -// by size hear it as a window resize. +// by size hear it as a window resize. Android's WebView leaves the visual +// viewport whole, so the keyboard's height comes from the app (MainActivity). { const view = window.visualViewport; let last = 0; + let androidKeyboard = 0; const fitView = () => { if (!view) return; - const height = Math.round(view.height); + const height = Math.round(android ? window.innerHeight - androidKeyboard : view.height); // iOS scrolls the page to show a focused field; the app does its own. if (window.scrollY) window.scrollTo(0, 0); if (height === last) return; @@ -53,6 +57,26 @@ const app = document.getElementById("app")!; }; view?.addEventListener("resize", fitView); view?.addEventListener("scroll", fitView); + window.addEventListener("android-keyboard", (e) => { + androidKeyboard = (e as CustomEvent).detail; + fitView(); + }); +} + +// Android's system bars. The WebView runs under them edge to edge, but older +// WebViews report the safe areas as 0, so their size is asked of the system +// (style.css `--inset-*`). Asked again on a resize: turning the phone moves them. +if (android) { + const fitInsets = () => + api + .insets() + .then((insets) => { + for (const [side, px] of Object.entries(insets)) + document.documentElement.style.setProperty(`--inset-${side}`, `${px}px`); + }) + .catch(() => {}); + void fitInsets(); + window.addEventListener("resize", () => void fitInsets()); } // --------------------------------------------------------------------------- @@ -317,6 +341,24 @@ document.addEventListener( ); document.addEventListener("touchcancel", () => (edgeSwipe = null), { capture: true, passive: true }); +// Android's Back button and gesture. Left to the WebView, they would leave the +// app, since it has no history. Back closes what is open over the screen +// first, then goes back a screen, and from the first one puts the app away. +if (android) + void onBackButtonPress(() => { + const scanning = document.querySelector(".scanner-cancel"); + const sheet = [...document.querySelectorAll("body > .scrim:not(.leaving)")].pop(); + const menu = document.querySelector(".menu:not([hidden])"); + if (document.querySelector(".lock-cover")) void api.toBackground(); + else if (scanning) scanning.click(); + // Tapping the scrim itself, outside the sheet, is what closes it. + else if (sheet) sheet.click(); + // A menu closes on a press anywhere outside it. + else if (menu) document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + else if (onBack) onBack(); + else void api.toBackground(); + }).catch(() => {}); + interface ScreenParts { title: string; back?: { label: string; onclick: () => void }; @@ -362,20 +404,15 @@ function screen(parts: ScreenParts) { h("div", { class: "title-block" }, heading, parts.subtitle), ...parts.body, ); - if (root) { - // The bar floats over a top-level screen, empty until the title has - // scrolled up under it. - scroll.addEventListener( - "scroll", - () => bar.classList.toggle("folded", scroll.scrollTop > large.offsetTop + large.offsetHeight - bar.offsetHeight), - { passive: true }, - ); - } else { - new IntersectionObserver( - ([entry]) => bar.classList.toggle("folded", !entry.isIntersecting), - { root: scroll, threshold: 0, rootMargin: "-8px 0px 0px 0px" }, - ).observe(large); - } + // The title folds into the bar once it has scrolled up under it. On a + // top-level screen the bar floats over the list, empty until then. Worked + // out from the scroll, not observed: Android's WebView reported a title in + // plain view as out of sight when it was observed. + scroll.addEventListener( + "scroll", + () => bar.classList.toggle("folded", scroll.scrollTop > large.offsetTop + large.offsetHeight - bar.offsetHeight), + { passive: true }, + ); const view = h("div", { class: "screen" }, bar, scroll, parts.footer, parts.dock); if (parts.dock) view.classList.add("docked"); if (root) view.classList.add("root"); diff --git a/mobile/src/style.css b/mobile/src/style.css index b7e90b7e..dcf08f45 100644 --- a/mobile/src/style.css +++ b/mobile/src/style.css @@ -55,7 +55,14 @@ /* The home indicator's gap; none while the keyboard is up, which covers it (main.ts sets `keyboard` on ). */ - --safe-bottom: env(safe-area-inset-bottom); + --safe-bottom: var(--inset-bottom); + + /* The system bars. main.ts sets these on Android, whose older WebViews + report the safe areas as 0 even edge to edge. */ + --inset-top: env(safe-area-inset-top); + --inset-right: env(safe-area-inset-right); + --inset-bottom: env(safe-area-inset-bottom); + --inset-left: env(safe-area-inset-left); color-scheme: light; font: 17px/1.35 var(--ui); @@ -196,9 +203,9 @@ code { grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; - height: calc(44px + env(safe-area-inset-top)); - padding: env(safe-area-inset-top) max(16px, env(safe-area-inset-right)) 0 - max(16px, env(safe-area-inset-left)); + height: calc(44px + var(--inset-top)); + padding: var(--inset-top) max(16px, var(--inset-right)) 0 + max(16px, var(--inset-left)); background: var(--canvas); box-shadow: 0 1px 0 transparent; transition: @@ -326,8 +333,8 @@ code { overflow-y: auto; overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch; - padding: 0 max(0px, env(safe-area-inset-right)) calc(32px + var(--safe-bottom)) - max(0px, env(safe-area-inset-left)); + padding: 0 max(0px, var(--inset-right)) calc(32px + var(--safe-bottom)) + max(0px, var(--inset-left)); } .title-block { @@ -351,7 +358,7 @@ code { } .screen.root .scroll { - padding-top: env(safe-area-inset-top); + padding-top: var(--inset-top); } .screen.root .title-block { @@ -945,7 +952,7 @@ html.scanning #app { justify-items: center; align-items: center; gap: 24px; - padding: env(safe-area-inset-top) 24px calc(28px + var(--safe-bottom)); + padding: var(--inset-top) 24px calc(28px + var(--safe-bottom)); color: #fff; } @@ -1054,8 +1061,8 @@ html.scanning #app { .dock { flex: none; - padding: 12px max(16px, env(safe-area-inset-right)) calc(12px + var(--safe-bottom)) - max(16px, env(safe-area-inset-left)); + padding: 12px max(16px, var(--inset-right)) calc(12px + var(--safe-bottom)) + max(16px, var(--inset-left)); background: var(--canvas); } @@ -1198,9 +1205,9 @@ html.scanning #app { display: flex; align-items: center; gap: 10px; - height: calc(56px + env(safe-area-inset-top)); - padding: env(safe-area-inset-top) max(16px, env(safe-area-inset-right)) 0 - max(16px, env(safe-area-inset-left)); + height: calc(56px + var(--inset-top)); + padding: var(--inset-top) max(16px, var(--inset-right)) 0 + max(16px, var(--inset-left)); background: var(--term); box-shadow: 0 0.5px 0 var(--hair); position: relative; @@ -1405,8 +1412,8 @@ html.scanning #app { display: flex; flex-direction: column; gap: 8px; - padding: 10px max(12px, env(safe-area-inset-right)) calc(10px + var(--safe-bottom)) - max(12px, env(safe-area-inset-left)); + padding: 10px max(12px, var(--inset-right)) calc(10px + var(--safe-bottom)) + max(12px, var(--inset-left)); background: var(--canvas); box-shadow: 0 -0.5px 0 var(--hair); } @@ -1550,8 +1557,8 @@ html.scanning #app { .float-bar { position: absolute; z-index: 3; - left: max(16px, env(safe-area-inset-left)); - right: max(16px, env(safe-area-inset-right)); + left: max(16px, var(--inset-left)); + right: max(16px, var(--inset-right)); bottom: calc(12px + var(--safe-bottom)); display: flex; align-items: center; @@ -1667,7 +1674,7 @@ html.scanning #app { flex-direction: column; gap: 20px; width: 100%; - max-height: calc(100% - env(safe-area-inset-top) - 24px); + max-height: calc(100% - var(--inset-top) - 24px); padding: 10px 20px calc(20px + var(--safe-bottom)); border-radius: 44px; background: var(--canvas);