zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Android Build

Developer reference for the Android target of epic #6757. This page covers the toolchain setup on Linux/WSL2 and macOS, the generated gen/android/ project, the Android-only config overlay, the platform-gating vocabulary, the dev/build loop, CI, and the on-device checks still owed. See tauri-app/CLAUDE.md for the iOS equivalent (gen/apple/) this mirrors.

One app, no generator

Android follows the same model as iOS: a single app, role forced to Leaf at runtime (resolve_role() in tauri-app/src/app_mode.rs), no ROOT/generator surface. There is no per-flavor tauri.conf.<name>.json override for Android — the base tauri-app/tauri.conf.json (identifier com.takazudo.zudotext) is the Android app's config, merged with the Android-only overlay below.

Toolchain

  • Rust — 1.90 or later, with the Android targets below.

  • Tauri CLI — v2.12 or later in the v2 line (cargo install tauri-cli --version '^2.12' --locked).

  • JDK 17 (CI-pinned) or 21 (verified) — JAVA_HOME must point at a JDK 17 or 21 install. CI builds on 17; a debug APK build, the Kotlin JVM tests and cargo check were also verified on OpenJDK 21.0.11 (Gradle 8.14.3, AGP 8.11.0). Kotlin JVM tests (./gradlew :app:testUniversalDebugUnitTest) need the Tauri-CLI-generated tauri.settings.gradle, so run one cargo tauri android build (or pnpm android:build) first; a bare ./gradlew in a fresh checkout fails.

  • Android SDK — ANDROID_HOME (or ANDROID_SDK_ROOT). Needs at minimum: platform-tools, platforms;android-36, build-tools;36.0.0 (compileSdk/targetSdk the generated template currently produces — see "Target SDK" below), and the NDK.

  • Android NDK — NDK_HOME pointing at a specific NDK version directory (e.g. $ANDROID_HOME/ndk/28.2.13676358), not the SDK's ndk-bundle.

  • Rust targets:

    rustup target add aarch64-linux-android x86_64-linux-android

    armv7-linux-androideabi and i686-linux-android are skipped unless a 32-bit device/emulator is actually needed — cargo tauri android init installs them too by default, but this project only builds aarch64 (real devices) and optionally x86_64 (emulator).

  • cargo-ndk — required by cargo tauri android init/build to drive per-ABI cross-compilation:

    cargo install cargo-ndk

Environment variables

export JAVA_HOME=/path/to/jdk-17
export ANDROID_HOME=/path/to/Android/Sdk
export NDK_HOME=$ANDROID_HOME/ndk/<version>

# Cross-compilation toolchain for the aarch64 target (adjust <version> and
# host triple for your platform's NDK layout).
TC=$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin
export CC_aarch64_linux_android=$TC/aarch64-linux-android24-clang
export CXX_aarch64_linux_android=$TC/aarch64-linux-android24-clang++
export AR_aarch64_linux_android=$TC/llvm-ar
export RANLIB_aarch64_linux_android=$TC/llvm-ranlib
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$TC/aarch64-linux-android24-clang

cargo tauri android init and android dev/build also need these set — they invoke cargo-ndk, which reads NDK_HOME (or ANDROID_NDK_HOME) to locate the toolchain.

Reaching an emulator or device from WSL2

WSL2 has no direct USB or graphics path to a physical device or to an emulator running on Windows, so pick one:

  • Emulator on Windows, WSL2 as the build machine — run the AVD from Android Studio (or emulator on the Windows side), then from WSL2:

    adb connect <windows-host-ip>:5555

    (Windows 11 + WSL "mirrored" networking mode can make localhost resolve to the Windows host directly, in which case adb connect localhost:5555 may work instead — check wsl.conf/.wslconfig for networkingMode=mirrored.)

  • USB device via usbipd — attach the device's USB interface into the WSL2 VM with usbipd-win (usbipd bind/usbipd attach --wsl), then adb devices inside WSL2 should list it directly.

  • Emulator inside WSL2 itself — needs /dev/kvm with nested virtualization enabled (systemsettings/BIOS "Virtualization" + Hyper-V's nested-VM support), and the invoking user must be in the host's kvm group. This is optional — most WSL2 setups don't have this, and the two options above cover the common case. Without it, $ANDROID_HOME/emulator/emulator -accel-check reports no KVM access and any AVD boot falls back to unusably slow software rendering.

If neither an emulator nor a device is reachable, the on-device launch check is not possible from that machine — open a deferred-verification issue labelled android with the exact checklist (build the APK, install it, watch adb logcat through boot, confirm it reaches onboarding, screenshot) rather than skipping it silently.

macOS toolchain

The same cargo tauri android commands work unchanged on macOS — install Android Studio (or the standalone cmdline-tools) for the SDK/NDK, JDK 17 (Android Studio bundles one, or brew install openjdk@17), and set the same JAVA_HOME/ANDROID_HOME/NDK_HOME triad shown above (macOS NDK toolchain binaries live under $NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64/bin, not linux-x86_64). macOS has none of the WSL2 networking/USB-passthrough hoops above: Android Studio's own AVD Manager runs the emulator directly (KVM's macOS equivalent, Hypervisor.framework, is available on every modern Mac with no group/BIOS setup), and a physical device over USB is visible to adb devices without usbipd. This page is otherwise platform-neutral; every pnpm android:* script and env var below applies identically on both hosts.

Platform-gating vocabulary

Android participates in the same shared-mobile gating the rest of the app already uses for iOS (epic #6757's gating-vocabulary decision — see the table in the epic issue):

  • TypeScript (packages/backend-bridge/src/platform.ts) — isTauriAndroid() is inEnv("tauri") && inEnv("android"); isTauriMobile() is isTauriIOS() || isTauriAndroid(); isTauriDesktop() is inEnv("tauri") && !isTauriMobile(). Code that means "any mobile Tauri build" reads isTauriMobile(), not two separate isTauriIOS() || isTauriAndroid() checks at the call site — that is how isDocCloudAvailable(), the TAURI_MOBILE_CAPABILITIES selection, and the mobile/mobile-device settings sections are gated (epic #6757's ts-predicates and mobile-settings topics). isTauriIOS() itself is unchanged and still means iOS specifically where the two platforms genuinely differ in kind, not just in availability — asset-upload-tab.tsx branches isTauriIOS() (photo-or-camera + document buttons) from the Android default (camera + multi-file buttons, see "Assets" under WebView runtime below) rather than collapsing both into one mobile path.

  • Rust (tauri-app/src/lib.rs and friends) — Tauri's own cfg(mobile) (true on iOS + Android) gates code that is desktop-vs-mobile in shape, e.g. mobile_paths::init(app) at boot and the #[cfg_attr(mobile, tauri::mobile_entry_point)] on run(). cfg(desktop) is the complement (true on macOS/Windows/Linux) and gates things mobile has no equivalent of at all: the splash window, tauri-plugin-shell. Truly Android-specific code (the Keystore plugin, the SystemUiPlugin window-insets/lifecycle plugin, the useHttpsScheme(true) call) is gated on #[cfg(target_os = "android")], mirroring how truly iOS-specific Rust code is gated on #[cfg(target_os = "ios")]. See l-env-feature-matrix (.claude/skills/l-env-feature-matrix/SKILL.md) for the full cross-surface gating catalog (settings fields/sections, view providers, command palette entries) now covering six environments including T-Android.

The generated project (gen/android/)

tauri-app/gen/android/ is committed, like gen/apple/ — it holds hand-editable Android Studio project sources (AndroidManifest.xml, build.gradle.kts, Kotlin activity, resources) that cargo tauri android init would otherwise regenerate on a fresh clone. Its own .gitignore files (gen/android/.gitignore, gen/android/app/.gitignore) ignore the build-generated pieces: local.properties, key.properties, tauri.properties, tauri.build.gradle.kts, jniLibs/**/*.so, and Gradle's build//.gradle/ output — never commit these, they either hold machine-local paths or (for key.properties / a keystore file) signing secrets.

Regenerate from scratch only if the project structure itself needs to change:

cd tauri-app
cargo tauri android init --ci

--ci skips interactive prompts. Re-running init against an existing gen/android/ is safe for the Gradle/Kotlin scaffold, but re-apply any hand edits documented below afterward (icons, POST_NOTIFICATIONS, the release signingConfigs block) since init does not know about them.

App icon

The generated project ships Tauri's own placeholder launcher icon. This app's real icon set lives at tauri-app/icons/android/ (adaptive icon: mipmap-anydpi-v26/ic_launcher.xml + values/ic_launcher_background.xml, plus per-density mipmap-*/ic_launcher{,_foreground,_round}.png) and is copied over the generated gen/android/app/src/main/res/ equivalents — including deleting the generated drawable/ic_launcher_background.xml and drawable-v24/ic_launcher_foreground.xml vector-drawable pair, which the project's own icon set doesn't use.

Target SDK

The committed project in gen/android/app/build.gradle.kts uses compileSdk = 36, targetSdk = 36, minSdk = 24. Tauri CLI 2.12's cargo tauri android init template now defaults to targetSdk = 37; those template defaults have not been applied to the committed project. SDK 35+ enforces edge-to-edge display, and android:windowSoftInputMode="adjustResize" no longer reliably resizes the content view around the on-screen keyboard — keyboard inset and safe-area handling on Android are not claimed to work from this scaffold alone; that's owned by the "Android WebView runtime behaviour" sub-issue of epic #6757, not this one.

Android-only config overlay (tauri.android.conf.json)

Tauri automatically merges tauri-app/tauri.android.conf.json into the base tauri.conf.json for any Android build/dev command — no --config flag needed. It currently sets:

  • bundle.resources: [] — excludes the macOS-only stub-skeleton/* resources (thin-launcher build artifacts) that the base config bundles for desktop builds; Android's own APK/AAB packaging doesn't consume bundle.resources at all, so this is belt-and-suspenders documentation of intent rather than a functional requirement.

  • bundle.android.minSdkVersion / versionCode — pinned explicitly (24 — Tauri's own floor — and 1) rather than left to the defaults, so a version bump is a deliberate config edit.

  • plugins.deep-link.mobile — {"scheme": ["zudotext"], "appLink": false}. The installed tauri-plugin-deep-link version supports a bare custom-scheme (non-app-link) mobile entry directly; its build.rs injects the matching <intent-filter> into gen/android/app/src/main/AndroidManifest.xml automatically on every Android build (look for the <!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. --> markers) — no manual manifest edit needed for this part.

  • app.windows[].useHttpsScheme: true for the main window — the epic's WebView-origin decision (https://tauri.localhost from day one; changing it later wipes IndexedDB/localStorage). This app builds its windows programmatically (tauri-app/src/native/window.rs's create_main_window, via WebviewWindowBuilder::new) rather than from config (WebviewWindowBuilder::from_config), so the config key alone would be inert — native/window.rs also calls .use_https_scheme(true) directly, gated #[cfg(target_os = "android")], to make it take effect. iOS is unaffected (its own existing WebView origin is untouched — this decision is Android-specific, made once, on day one).

CSP does not need a Android-specific https://tauri.localhost entry: 'self' in the CSP directives already covers the page's own origin regardless of what that origin is. The sync-server origin work (epic #6757's #6760, already merged) covers the server side — https://tauri.localhost is in the sync-server's CORS/CSRF allowlists.

AndroidManifest.xml

Tauri's own Android template already sets android:launchMode="singleTask" on MainActivity (so the sign-in browser-handoff callback reuses the running activity instead of spawning a new one) and declares INTERNET. This scaffold adds:

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

for the shared-mobile local-notifications feature (AppSettings.mobile.notificationsEnabled, requested at runtime on Android 13+/API 33 through the same plugin call as iOS).

Local file storage

Android's local project root is created under Tauri's private app_data_dir() as app_data_dir()/zudo-text/. Local filesystem commands are confined to that root and the app's private config directory; they do not use shared external storage. The app does not expose a FileProvider for these files.

This local tree is separate from the encrypted cloud workspace. Changing the Android local root does not reset, move, or migrate cloud documents. Android's previous pre-release app-specific external Documents/zudo-text/ layout (getExternalFilesDir(DIRECTORY_DOCUMENTS)/zudo-text/) is not migrated: the app leaves files already there untouched, and users should treat pre-release local layouts as re-creatable from scratch.

Run the portable manifest and storage-config regression check after editing Android manifest or resource files:

python3 scripts/check-android-provider-config.py

Capabilities (tauri-app/capabilities/android.json)

Adds haptics:default and notification:default — the Rust plugins (tauri-plugin-haptics, tauri-plugin-notification) are already registered for every platform in tauri-app/src/lib.rs; what was missing was the Android capability grant (the iOS capability file already had both). For the deep-link plugin it grants only deep-link:allow-get-current, not deep-link:default: the renderer reads the cold-start launch URL through getCurrent() (see WebView runtime § Sign-in across process death) and otherwise only subscribes to the onOpenUrl event, which needs no invoke-time permission. It never calls register / unregister / is_registered. Desktop already holds the same grant through deep-link:default in capabilities/default.json, where the read is a no-op in practice (a consumed or stale callback is skipped or rejected without touching a pending sign-in); iOS grants nothing, so getCurrent() rejects there and the hook ignores the rejection.

The base Android and iOS capabilities grant local app content only and contain no remote origins. Release commands such as pnpm android:build:release use the base config plus the automatic Android overlay; they do not select tauri.conf.mobile-dev.json, so the effective Android release capability set contains no remote grant. Tauri 2.11 ACL-checks custom-command IPC from remote origins, so only mobile development explicitly selects tauri-app/tauri.conf.mobile-dev.json through --config (pnpm android:dev). This overlay sits outside the scanned capabilities/ directory and replaces the capability selection with the default, android, and ios references plus separate Android/iOS inline remote grants. Each remote grant applies only to its platform's main window, has local: false, and mirrors that platform's base permissions (including Android-only deep-link:allow-get-current).

The dev grants retain only HTTP port 37461 on localhost, 127.0.0.1, and the existing private IPv4 patterns (10.*.*.*, 172.16.*.* through 172.31.*.*, 192.168.*.*) used by TAURI_DEV_HOST. An IPv6 iOS TUN host is not covered; if needed, add its exact host and port to the iOS inline dev grant in the overlay, never the base capability. Desktop's default policy and native-e2e's explicit default/WDIO selection are unchanged.

pnpm check:mobile-dev-capabilities (also part of pnpm check) structurally models Tauri 2.10's capability selection and target filtering, checks Android and iOS separately, and includes a hostile release-selection fixture. It guards the config contract; it does not run Tauri's native ACL resolver or prove device IPC. For native evidence, distinguish tauri-build's raw scanned capability definitions from the effective authority: tauri-codegen selects references and inline capabilities from the merged config, filters them for the target, and resolves permission manifests. Record that target-specific resolved output and inspect the built APK/ACL artifacts. Do not infer active grants from the raw capabilities.json definition list or assume tauri.conf.json is a plain APK entry.

Secure storage (Android Keystore)

The E2E workspace master key + salt and the other keyStore payloads go through the same keychain_* command contract as the macOS login Keychain (tauri-app/src/commands/keychain.rs). keychain_is_supported returns true on Android, so the renderer's store selection (workspace-key-store.ts, the Doc Cloud token store) routes Android to native storage with no Android-specific frontend branch, and the first native operation purges any older plaintext localStorage copies. iOS also reports supported and uses ordinary app-private Keychain; its physical-device/signing release gate remains open on #4458.

Backing design

  • Key — one AES-256-GCM key per install in Android Keystore, alias <applicationId>.workspace-keys (the applicationId plays the role the bundle-id service name plays on macOS). Generated non-exportable, PURPOSE_ENCRYPT | PURPOSE_DECRYPT, setRandomizedEncryptionRequired(true) — the Keystore picks a fresh 12-byte IV for every encryption and refuses caller-supplied ones. It is TEE-backed where the device has one; StrongBox is not requested, because devices without it would fail key generation and need a second path. No user-authentication requirement, since cold-start reads are automatic.

  • Ciphertext — v1:<hex iv>:<hex ciphertext+tag> per account in the app-private SharedPreferences file zudotext_secure_store. Each ciphertext is bound to its account name through GCM additional authenticated data, so an entry copied under another account fails authentication. Writes use commit(), so a command resolves only once the entry is durable.

  • No EncryptedSharedPreferences — androidx.security:security-crypto is deprecated. The store is plain javax.crypto over the platform Keystore, with no extra dependency.

  • Backup — the manifest sets android:allowBackup="false", and res/xml/data_extraction_rules.xml (API 31+: cloud backup and device transfer) and res/xml/backup_rules.xml (API ≤ 30, fullBackupContent) additionally exclude zudotext_secure_store.xml, the WebView storage (app_webview/, which holds the auth session token, device id and outbox cursors), databases and files. The rules are needed on top of allowBackup="false" because Android 12+ device-to-device transfer ignores that attribute. Keystore keys are never backed up, so a restored copy of the secure-store file could never be decrypted anyway.

  • Key gone → missing, never a crash — if an entry exists but its key does not (a restore that still carried the prefs file, or a cleared Keystore), or decryption fails authentication, or the key was permanently invalidated, keychain_read returns null and drops the unreadable entries. The renderer treats that as "no persisted key" and shows the re-enter-password unlock. A write that meets an invalidated key resets the store and retries once. keychain_delete_all (sign-out) clears every entry and deletes the key.

  • Error messages — Kotlin rejections carry only the operation and exception type, never the message: Jackson messages can quote the invoke arguments, which include the secret. Nothing is passed to Invoke.reject's exception parameter, which would log it. On the Rust side, serde (de)serialization errors are reported generically for the same reason.

Why an in-repo plugin, not a community plugin

The Kotlin half is a small in-repo Tauri mobile plugin inside the committed Android app module (gen/android/app/src/main/java/com/takazudo/zudotext/keystore/). tauri-app/src/native/android_keystore.rs registers it as an inline Rust plugin (register_android_plugin), and the keychain_* commands forward to it with run_mobile_plugin_async.

  • The contract is fixed and small (read / write / delete / delete-all of opaque strings). The community keychain/stronghold plugins expose their own JS APIs and storage shapes. None of them guarantees all of the required properties: a non-exportable Keystore AES-GCM key, no EncryptedSharedPreferences, backup exclusion, and "missing" instead of an error when the key is gone. Adopting one would mean either a second frontend path or an adapter around a different contract.

  • About 350 lines of Kotlin with no new Gradle or Cargo dependency is less surface to audit than a third-party plugin that sits on the most security-sensitive path in the app.

  • Keeping it in the app module avoids a separate plugin crate with its own android/ library project and build.rs wiring. The inline plugin has no JS invoke handler and no capability grant, so the renderer cannot call the Kotlin commands directly — the only way in is through the keychain_* commands.

Tests

  • Kotlin JVM unit tests (gen/android/app/src/test/…/keystore/SecretStoreTest.kt) cover the store logic over a software AES-GCM stand-in: round trip, fresh IV per write, missing key, foreign key, cross-account replay, malformed envelopes, invalidated-key retry, and delete / delete-all. Run them with:

    cd tauri-app/gen/android && ./gradlew :app:testUniversalDebugUnitTest --tests 'com.takazudo.zudotext.keystore.*'
  • The Rust response-shape test lives in android_keystore.rs, and the renderer store-selection tests in tauri-app/renderer/lib/workspace-key-store.test.ts.

  • On-device checks (restart persistence, no key material in localStorage/IndexedDB via chrome://inspect, reinstall → re-enter-password prompt) need an emulator or device and are tracked in the deferred-verification issue linked from epic #6757.

WebView runtime

How the renderer behaves in the Android WebView (Chromium), from sub-issue #6766. Each fix is keyed on a capability or pointer type where possible, and iOS and desktop keep their behaviour. The native half is a second in-repo plugin next to the Keystore one: gen/android/app/src/main/java/com/takazudo/zudotext/systemui/SystemUiPlugin.kt, registered by tauri-app/src/native/android_system_ui.rs and reached through the mobile_window_insets / mobile_move_task_to_back commands (src/commands/mobile_system_ui.rs).

Keyboard and safe area

targetSdk 36 enforces edge-to-edge, so adjustResize no longer resizes the WebView. SystemUiPlugin installs a WindowInsets listener on the WebView and handles both halves of the problem:

  • Keyboard: the WebView's parent is padded by the IME inset, so the layout viewport and visualViewport both end at the keyboard top. That is the geometry every other renderer path already reads — the compact mobile shell (use-compact-mobile-viewport.ts), 100dvh, and the command palette's max-height — and useKeyboardInset computes 0, so the keyboard is never counted twice.

  • Safe area: system-bar and display-cutout insets are pushed to the page as a zudotext:native-insets event (and pulled once at boot through mobile_window_insets, for pushes that fired before the page loaded). installNativeInsets() writes them to --native-safe-area-inset-*, and tokens.css resolves --safe-top/right/bottom/left from those before falling back to env(). While the keyboard is up, the bottom inset drops to the part of the navigation bar the keyboard does not cover (resolveSafeAreaInsets). The listener replaces the WebView's own inset handling, so env(safe-area-inset-*) is never mixed in.

Surfaces use the --safe-* tokens instead of env() directly: mobile-base.css, MobileFullscreenDialog, the command palette, and the full-bleed screens and overlays.

IME (Gboard)

CodeMirror already ignores key events while composing, which covers the edit-key-bar executor (editorHasComposition), cm-inline-command and cm-ghost-text. Gboard composes Latin text word by word and reports composition keystrokes as keyCode 229, so the non-CodeMirror Enter/Escape handlers — find/replace, the inline command panel's follow-up input, the kanban create-card flyout, spreadsheet cell and formula bar, InlineEditField — treat keyCode === 229 as composing, alongside isComposing.

Long-press

Chromium dispatches contextmenu for a touch long-press, and a preventDefault() on it cancels the text-selection handles and action bar. lib/touch-selection-guard.ts stops a touch or pen contextmenu whose target is editable or rendered text (inputs, [contenteditable], .cm-content, .markdown-body) before any app handler sees it. A mouse right-click still reaches every custom menu, and on app chrome the env-touch user-select: none means a long-press starts no native selection. WebKit on iOS never dispatches contextmenu for a long-press, so nothing changes there.

Assets

The Android WebView opens the camera only for an image/* input with capture; a plain input opens the system file picker, which also reaches Photos. The Upload tab therefore offers Camera (capture="environment") and Files (multiple) on Android, through the shared MobileFilePicker that iOS uses for its photo/document pair. No CAMERA permission is declared: wry launches the camera app's capture intent, which does not need it.

Lifecycle sync

SystemUiPlugin dispatches zudotext:native-pause and zudotext:native-resume from the activity lifecycle.

  • Pause: useIdleBoundaryFlush treats the pause like visibilitychange: hidden, so renderer-held edits go into the persisted outbox before the OS can freeze or kill the process.

  • Resume: useResumeSync is mounted on every platform (tauri-app/renderer/app.tsx) and collapses visibility, native resume and online into one signal. It calls reconcileBeforeReplay() to pull and reconcile remote state before the outbox replays, and resumeWebSocket(), which skips a pending reconnect backoff or pings a socket that still claims to be open and replaces it if no frame arrives within 5 s. Expediting the outbox drain is reached through cloud-sync-bridge.ts, not called by the hook itself.

Sign-in across process death

Android often kills the app while the browser holds the sign-in page, and the callback deep link then starts a fresh process.

  • The pending client_state nonce is persisted in localStorage (better-auth-desktop.ts, 30-minute TTL) as well as held in memory. It is consumed on success, on a matching error, and on sign-out.

  • useAuthDeepLink reads the launch URL with the deep-link plugin's getCurrent() after subscribing to onOpenUrl, and remembers consumed callbacks so a remount cannot replay them.

Back button and gesture

lib/android-back-button.ts registers Tauri's back-button listener, which takes over from the WebView's history navigation. Each press sends Escape to the topmost visible [role=dialog], [role=alertdialog], [aria-modal=true] or [role=menu]. The overlay keeps owning Back while its close or exit transition is still visible; repeated presses cannot skip it and background the app. A non-modal role="dialog" that sits in-flow inside the app root (the empty-frame chooser, the kanban side panel) is part of the screen rather than an overlay, so it does not count. Once no eligible overlay remains, Back calls mobile_move_task_to_back, which sends the app to the background like Android's root-screen Back.

Device or emulator regression runbook

Run this on an installed Android build and record the full app Git SHA, device or emulator model, Android API level, and whether each step used the hardware button or system gesture. The physical acceptance is tracked in #6776 item 13; keep it deferred while no Android runtime is available.

  1. Open Snippets, enter a snippet's fill or editor stage, and focus a Vim-enabled editor with an unsaved change. With the software keyboard visible, press system Back once. Record whether Android first dismisses the keyboard while preserving the overlay and draft. Press Back again after the keyboard is hidden and confirm that the overlay's own Back route advances one stage without losing the edit or Vim state.

  2. Test the row menu on its own first: open it, press Back, and confirm the menu closes while the palette stays open. Press Back rapidly while the menu's exit is still visible and confirm it continues to own Back; wait for it to disappear. Reopen the menu, choose Delete, and press Back during confirmation. Confirm only the confirmation closes and deletion does not run. Choosing Delete may already have started the menu's exit, so wait for that transition to finish instead of requiring another menu-closing press. Then press Back once and confirm the palette follows its current staged route (moves back one stage or closes if it was already at the root).

  3. Reopen an overlay with a visible exit animation and press Back rapidly during the animation. Confirm each press remains owned by that overlay until it is hidden or removed, then confirm the next Back reaches the next layer.

  4. Close all overlays and press Back at the root screen. Confirm the app moves to the background. Bring it back from Android's task switcher and confirm the same workspace returns, the Back listener still works once, and no duplicate Back action occurs.

  5. Repeat the overlay and root cases with both the hardware Back control (or emulator equivalent) and the system Back gesture where available. Note any difference in keyboard dismissal, event delivery, or app restoration.

The renderer tests use synthetic DOM events, mocked Tauri registration/invoke APIs, and narrow layout stubs. They prove routing and transition guarding in the renderer; they do not prove Android delivers a hardware or gesture event, that the IME consumes the first press, or that task backgrounding and restoration work on a device.

Tests and on-device checks

Unit tests cover inset math (native-insets.test.ts, WebInsetsTest.kt), keyboard reconciliation (use-keyboard-inset.test.ts), the resume trigger and reconnect (app-resume.test.ts, use-resume-sync.test.tsx, ws-manager.test.ts § resume, outbox.test.ts § expedite), the cold-start deep-link read (use-auth-deep-link.test.ts, better-auth-desktop.test.ts § process death), Back routing and native-listener registration (android-back-button.test.ts, android-back-button-overlays.test.tsx, and the snippet palette cases in snippet-palette.test.tsx), the long-press guard, and the Upload tab split. These are renderer-level proofs only; use the runbook above for Android acceptance. The broader on-device checklist is tracked in #6775 (deferred-verification, android). For viewport trace acceptance, record the exact app build SHA and device/API level, then follow the trace runbook: enable in Settings → Mobile, type only synthetic fixture text, copy the trace, disable recording, and paste it into a local JSON file. If native clipboard copy is denied, verify the app shows failure and creates no download; a mocked backend does not count as device evidence. That acceptance remains deferred under #6776, and Samsung IME evidence remains unverified under #7051.

Published URL sharing

renderer/hooks/use-native-share.ts prioritizes native Android through a lazy invoke("mobile_share_text", { request: { title?, text?, url? } }). The Rust app broker forwards to the in-repo SystemUiPlugin.shareText, which launches an ACTION_SEND text/plain chooser. It preserves the selected publication's exact URL, title and text. Clipboard grants already exist; no new direct JavaScript plugin grant is needed.

The renderer returns dispatched, copied, cancelled or failed. Native {status: "dispatched"} means startActivity returned, not that a recipient was selected or content delivered. Android does not infer cancellation on resume. Other platforms retain Web Share: fulfilled promises mean dispatch, AbortError is quiet cancellation, and other failures remain visible.

Known pre-dispatch broker rejections (share_text_unsupported, share_text_unavailable, share_text_invalid_arguments, share_text_empty_content, share_text_launch_failed) and the exact pinned Tauri missing-command diagnostic (Command mobile_share_text not found) may copy the URL once. share_text_dispatch_unknown, transport errors and malformed/unknown replies never copy, because dispatch may already have occurred. Unsupported Web Share may also copy the URL; without a URL that fallback fails. A clipboard write must resolve before the UI says URL copied. Cancellation quietly retains the selected publication for retry; failure retains it and shows Share failed. Try again. Haptics mark the invocation tap, and no outcome claims successful delivery. Error logs never include share payloads or native rejection details.

Android SDK compilation and Kotlin JVM checks establish code/build behavior only. Physical chooser, recipient content and dismiss/resume acceptance remain unverified, tracked under #6776. Record the integrated app build SHA/APK and device OS/WebView versions, then use an already published synthetic fixture URL: open Share, inspect title/text/exact URL in a safe recipient preview, dismiss and return without clipboard changes or delivery feedback, and retry. Check one URL copy under a deliberately unavailable native seam in a controlled test, and confirm the original note/publication is unchanged. Do not create a public page or send to a person for validation without separate authorization. Renderer tests and APK compilation cannot close this runtime gate.

Dev loop

pnpm android:dev (cargo tauri android dev --config tauri.conf.mobile-dev.json) runs the app against a live Vite dev server on a device/emulator. The dev server needs to bind somewhere the device can actually reach, which is never plain localhost from a device's point of view. tauri-app/vite.config.ts follows the standard Tauri template shape:

const host = process.env.TAURI_DEV_HOST;

server: {
  port: 37461,
  strictPort: true,
  host: host || false,
  hmr: host ? { protocol: "ws", host, port: 37465 } : undefined,
}

cargo tauri android dev sets TAURI_DEV_HOST itself (to the host machine's reachable address) when invoking beforeDevCommand; outside mobile dev the server stays bound to localhost exactly as before, so desktop pnpm tauri:dev / pnpm writing:dev are unaffected. Port 37465 is dedicated to Android HMR to avoid the other tauri-app dev ports already in use (37461 writing, 37462 admin, 37464 native-e2e).

Required VITE_* keys

tauri-app/vite.config.ts's desktop-build env guard (desktop-env-guard.ts) also gates Android: the Tauri CLI exports TAURI_ENV_PLATFORM for beforeBuildCommand/beforeDevCommand on every platform it builds for, including android, and the guard hard-fails a vite build whenever that variable is set and any of VITE_BETTER_AUTH_URL, VITE_SYNC_SERVER_URL, VITE_PUBLISH_SERVER_URL, VITE_DOC_CLOUD_ORIGIN is missing (or, for a bundle build, non-https://). Practically: an Android build needs the exact same tauri-app/.env as a desktop build — copy tauri-app/.env.example to tauri-app/.env and fill in the real (already-public) Worker origins. Locally this is a one-time manual step; in CI, .github/workflows/android-apk-build.yml (below) copies .env.example to .env before pnpm android:build runs, with optional repository-variable overrides.

cargo tauri android build rewrites tauri-app/Cargo.toml (it adds macos-private-api to the desktop-only tauri dependency's features), so the tree is dirty after every local Android build. Revert it with git checkout -- tauri-app/Cargo.toml before committing.

Gradle dependency resolution can fail with 429 Too Many Requests from Maven Central. That is transient: rerun, optionally with GRADLE_OPTS="-Dorg.gradle.internal.repository.max.retries=12 -Dorg.gradle.internal.repository.initial.backoff=4000 -Dorg.gradle.workers.max=1".

Scripts

pnpm android:dev              # cargo tauri android dev --config tauri.conf.mobile-dev.json
pnpm android:build            # debug APK, aarch64 only
pnpm android:build:release    # release APK, aarch64 only — needs signing env vars below

android:build/android:build:release pass --target aarch64 --apk — only the arm64 device ABI, not the full multi-ABI matrix, and APK rather than AAB (Play Store distribution, which wants an AAB, is an explicit follow-up outside this epic). Add --target x86_64 too when testing against an x86_64 emulator image.

Release signing

gen/android/app/build.gradle.kts reads the release keystore from environment variables — never committed:

export ANDROID_KEYSTORE_PATH=/absolute/path/to/release.keystore
export ANDROID_KEYSTORE_PASSWORD=...
export ANDROID_KEY_ALIAS=...
export ANDROID_KEY_PASSWORD=...

When ANDROID_KEYSTORE_PATH is unset, the release build type has no signingConfig and Gradle produces an unsigned release APK (still useful for local size/proguard inspection); CI supplies these four as secrets for an actual signed release build.

CI

Two workflows cover Android, neither a required PR check yet (epic #6757's CI decision row):

  • .github/workflows/android-check.yml — cargo clippy --all-targets --target aarch64-linux-android -- -D warnings on every PR touching tauri-app/**. Cheap regression catch (e.g. an ungated desktop-only Tauri API) without a full Gradle/NDK build.

  • .github/workflows/android-apk-build.yml — weekly (schedule) plus workflow_dispatch, green since 2026-09-28. Installs JDK 17, the Android SDK (platforms;android-36, build-tools;36.0.0), NDK r28c, cargo-ndk 4.1.2 and the Tauri CLI (cargo install tauri-cli --version 2.12.0 --locked), and caches cargo/Gradle. It then runs, in order: pnpm android:build (debug APK), the Kotlin JVM tests (./gradlew --no-daemon :app:testUniversalDebugUnitTest, which reuse the tauri.settings.gradle the build generated), scripts/android-apk-assert.sh (aapt2 checks on the APK: package name, min/target SDK, deep-link scheme, launch mode, exported flag, POST_NOTIFICATIONS present, CAMERA absent), and finally uploads app-universal-debug.apk as the zudotext-debug-apk artifact with 7-day retention. The build copies tauri-app/.env.example to a local .env file in the same directory, so the four VITE_* keys above are optional overrides: the repository variables (vars.VITE_BETTER_AUTH_URL, vars.VITE_SYNC_SERVER_URL, vars.VITE_PUBLISH_SERVER_URL, vars.VITE_DOC_CLOUD_ORIGIN) win when set, and are the same already-public origins as the example file, not credentials. There is no release signing in CI.

Verified on this scaffold

  • pnpm android:build produces a debug APK on Linux/WSL2 at tauri-app/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk.

  • pnpm tauri:build (the desktop thin-launcher build) is unaffected — the Android overlay only applies when Tauri resolves the android platform target.

  • Every sub-issue's Rust (cargo check --target aarch64-linux-android), TypeScript (vitest), and Kotlin JVM (./gradlew :app:testUniversalDebugUnitTest) test suites pass. See the per-topic detail linked from each section above.

Known limits

No on-device confirm pass has run

Epic #6757's implementation session ran entirely in a WSL2 sandbox with no /dev/kvm access and no adb-visible device or emulator. Every Android sub-issue built and unit-tested clean, but nothing in this epic has been installed or launched on a real Android runtime. The Wave 5 confirm pass, #6767, was deferred, not passed — its full checklist lives in #6776, with area-specific detail in #6773 (first launch to onboarding), #6774 (Keystore secure-storage persistence, no key material in web storage, backup exclusion) and #6775 (keyboard/safe-area, Gboard IME, long-press, assets, lifecycle sync, sign-in across process death, back button). Run that checklist on a real device or a KVM/Hypervisor.framework-backed emulator before treating any of it as proven.

One precondition for real sign-in tests is already met:

  • The sync-server origin change is deployed. https://tauri.localhost (Android's WebView origin under useHttpsScheme: true) is in WEB_EDITOR_ALLOWED_ORIGINS/BETTER_AUTH_TRUSTED_ORIGINS, and .github/workflows/deploy-sync-server.yml deploys the sync server on every push to main (latest runs green), so signing in against the production sync server needs no extra step.