feat(mobile): run on Android, and release a signed APK (#1044)

* feat(mobile): run on Android

The app builds and runs on Android, and fits there.

- Back button and gesture close what is open over the screen, then go
  back a screen, and from the first one send the app to the background.
  Left to the WebView they closed the app, since it has no history.
- The system bars: the page asks their size of the app (`insets`, over
  JNI), since WebViews before 140 report the safe areas as 0 even edge
  to edge. The CSS reads them as `--inset-*`, which iOS still fills
  from `env()`.
- The keyboard: edge to edge, the WebView is not resized for it and its
  visual viewport stays whole, so the keyboard covered the message box.
  MainActivity hands its height to the page, which lays out as on iOS.
- The title folds into the bar from the scroll position on every screen.
  The observer it used reported a title in plain view as out of sight on
  Android, so the bar started folded.
- src-tauri/gen/android is kept in the repo for that MainActivity.

* ci(mobile): release a signed Android APK

A `mobile-v<x.y.z>` tag builds the app for arm64 at that version, signs
it with the release key and attaches it to a draft release. The mobile
app is versioned apart from the desktop's `v*` tags.

- The release is never marked latest: the desktop updater reads
  /releases/latest and would take it for a desktop release.
- The APK's certificate is checked against the release key's, since one
  signed with another key could not be installed over earlier ones.
- The NDK is pinned, and the version must be x.y.z: Tauri derives the
  versionCode from it, and Android installs over a build only when that
  is higher.
- Gradle signs a release build when keystore.properties names a key;
  CI writes it from secrets.
- The release library is stripped: 30 MB to 20, the APK 32 to 23.

* ci: tell people how to install the phone app

The nightly release notes and each mobile-v release now say how to get
the app on a phone: the TestFlight link for iPhone, and the newest
mobile-v release's APK for Android. Both read .github/mobile-install.md.
This commit is contained in:
l0ng-ai
2026-09-30 17:17:06 +08:00
committed by GitHub
parent d40233b572
commit 0c2033ab07
50 changed files with 1408 additions and 42 deletions
+6
View File
@@ -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-<version>-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.
+140
View File
@@ -0,0 +1,140 @@
name: Mobile
# The phone app's Android build: a signed APK people install by hand. A
# `mobile-v<x.y.z>` 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<major>.<minor>.<patch>, 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"
+3 -1
View File
@@ -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"
+26 -4
View File
@@ -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<x.y.z>` 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
+1
View File
@@ -5809,6 +5809,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"iroh",
"jni 0.21.1",
"objc2",
"rustls",
"serde",
+10
View File
@@ -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
@@ -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
+20
View File
@@ -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
@@ -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
@@ -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")
+21
View File
@@ -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
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.tty7_mobile"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -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)
}
}
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.tty7_mobile" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">"tty7"</string>
<string name="main_activity_title">"tty7"</string>
</resources>
@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.tty7_mobile" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -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")
}
@@ -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")
}
@@ -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()
}
}
@@ -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<Project> {
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<ApplicationExtension> {
@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
)
}
}
}
}
}
@@ -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
Binary file not shown.
@@ -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
+185
View File
@@ -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" "$@"
+89
View File
@@ -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
@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'
+157 -2
View File
@@ -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<Insets> {
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),
})
}
}
+13
View File
@@ -86,6 +86,19 @@ export const forget = (hostId: string) => invoke<void>("forget", { hostId });
/** Sets the style of what the page does not draw: status bar, keyboard. */
export const appearance = (style: "system" | "light" | "dark") => invoke<void>("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>("insets");
/** Sends the app to the background, as Back from the first screen does. */
export const toBackground = () => invoke<void>("to_background");
export function watch(hostId: string, onMsg: (msg: TreeMsg) => void) {
const onEvent = new Channel<TreeMsg>();
onEvent.onmessage = onMsg;
+54 -17
View File
@@ -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<number>).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<HTMLElement>(".scanner-cancel");
const sheet = [...document.querySelectorAll<HTMLElement>("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");
+25 -18
View File
@@ -55,7 +55,14 @@
/* The home indicator's gap; none while the keyboard is up, which covers
it (main.ts sets `keyboard` on <html>). */
--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);