Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
2c4f63f
feat(android): implement mobile navigation, opt-in background service…
codex Oct 8, 2026
d641f1d
fix(android): bind saved authentication and verify mobile lifecycle i…
codex Oct 8, 2026
a9af388
fix(android): bound reconnect attempts and keep compact input panels …
codex Oct 8, 2026
b475d9a
test(android): verify bounded recovery and precise packaged service p…
codex Oct 8, 2026
50b9946
fix(android): honor SDK36 manifest namespaces and foreground permissi…
codex Oct 8, 2026
25d4c4d
fix(android): prevent delayed authentication from restoring deleted p…
codex Oct 8, 2026
0db1ba6
fix(android): scope credential completion returns to the synchronized…
codex Oct 8, 2026
f7f057d
test(android): correct new harness flow and reject undeclared helpers…
codex Oct 8, 2026
3033505
test(android): isolate IME and lifecycle acceptance within owned harn…
codex Oct 8, 2026
d5d53b6
ci(android): run candidate checks once per exact draft PR head
codex Oct 8, 2026
674ac3d
fix(android): render buffered host verification and synchronize nativ…
codex Oct 8, 2026
c74c486
test(android): isolate WebView debug pages across owned harness phases
codex Oct 8, 2026
952f1ea
test(android): inspect read-only paste through the mobile actions panel
codex Oct 8, 2026
f28f6db
test(android): wait for actual IME insets and current owned notificat…
codex Oct 8, 2026
38e995c
test(android): find the nonfocused clipboard preview in system windows
codex Oct 8, 2026
85dd17a
Observe rebuilt Android connection forms and identify invalid endpoin…
codex Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions .github/workflows/android-prototype.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
name: Android prototype checks and APK
on:
push:
# Product commits are checked by their draft PR with an exact head checkout.
# Keep the original prototype push route; avoid duplicate candidate runs.
branches: [prototype/android-direct-ssh]
paths: [mobile/**, tabby-ssh/src/session/tmux*, tabby-ssh/src/session/reconnect.ts, .github/workflows/android-prototype.yml]
pull_request:
branches: [feature/ssh-tmux-recovery]
branches: [feature/ssh-tmux-recovery, candidate/android-mobile-ux-20261008]
paths: [mobile/**, tabby-ssh/src/session/tmux*, tabby-ssh/src/session/reconnect.ts, .github/workflows/android-prototype.yml]
workflow_dispatch:
permissions:
Expand All @@ -14,7 +16,7 @@ concurrency:
cancel-in-progress: true
jobs:
checks:
if: github.event.repository.visibility == 'public' && github.repository == 'Mafei/tabby' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'Mafei/tabby' && github.event.pull_request.head.ref == 'prototype/android-direct-ssh') || (github.event_name != 'pull_request' && github.ref == 'refs/heads/prototype/android-direct-ssh'))
if: github.event.repository.visibility == 'public' && github.repository == 'Mafei/tabby' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'Mafei/tabby' && (github.event.pull_request.head.ref == 'prototype/android-direct-ssh' || github.event.pull_request.head.ref == 'candidate/android-mobile-product-20261008')) || (github.event_name != 'pull_request' && (github.ref == 'refs/heads/prototype/android-direct-ssh' || github.ref == 'refs/heads/candidate/android-mobile-product-20261008')))
runs-on: ubuntu-24.04
timeout-minutes: 25
steps:
Expand Down Expand Up @@ -73,7 +75,9 @@ jobs:
run: npm run test:delivery --prefix mobile
- name: Packaged WebView policy rejection tests
if: always() && steps.install.outcome == 'success'
run: python3 -m unittest discover -s mobile/test -p test_android_web_security.py -v
run: |
python3 -m unittest discover -s mobile/test -p test_android_web_security.py -v
python3 -m unittest discover -s mobile/test -p test_android_manifest_policy.py -v
- name: Rust safety and real SSH integration
id: native
if: always() && steps.install.outcome == 'success'
Expand All @@ -94,7 +98,7 @@ jobs:
if: always() && steps.install.outcome == 'success'
run: node mobile/scripts/test-jvm-policy.mjs
arm64-core:
if: github.event.repository.visibility == 'public' && github.repository == 'Mafei/tabby' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'Mafei/tabby' && github.event.pull_request.head.ref == 'prototype/android-direct-ssh') || (github.event_name != 'pull_request' && github.ref == 'refs/heads/prototype/android-direct-ssh'))
if: github.event.repository.visibility == 'public' && github.repository == 'Mafei/tabby' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'Mafei/tabby' && (github.event.pull_request.head.ref == 'prototype/android-direct-ssh' || github.event.pull_request.head.ref == 'candidate/android-mobile-product-20261008')) || (github.event_name != 'pull_request' && (github.ref == 'refs/heads/prototype/android-direct-ssh' || github.ref == 'refs/heads/candidate/android-mobile-product-20261008')))
runs-on: ubuntu-24.04-arm
timeout-minutes: 25
steps:
Expand Down Expand Up @@ -136,7 +140,7 @@ jobs:
TABBY_TEST_TMUX: /usr/bin/tmux
run: node mobile/scripts/test-fixture-jni.mjs --deferred
android:
if: github.event.repository.visibility == 'public' && github.repository == 'Mafei/tabby' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'Mafei/tabby' && github.event.pull_request.head.ref == 'prototype/android-direct-ssh') || (github.event_name != 'pull_request' && github.ref == 'refs/heads/prototype/android-direct-ssh'))
if: github.event.repository.visibility == 'public' && github.repository == 'Mafei/tabby' && ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'Mafei/tabby' && (github.event.pull_request.head.ref == 'prototype/android-direct-ssh' || github.event.pull_request.head.ref == 'candidate/android-mobile-product-20261008')) || (github.event_name != 'pull_request' && (github.ref == 'refs/heads/prototype/android-direct-ssh' || github.ref == 'refs/heads/candidate/android-mobile-product-20261008')))
runs-on: ubuntu-24.04
timeout-minutes: 45
strategy:
Expand Down Expand Up @@ -237,6 +241,14 @@ jobs:
if [ -x "$ANDROID_HOME/platform-tools/adb" ]; then
"$ANDROID_HOME/platform-tools/adb" -s emulator-5554 emu kill || true
fi
- name: Upload fixture-only runtime images on acceptance failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: Android-Runtime-Images-api${{ matrix.api }}-${{ matrix.form }}-${{ github.event.pull_request.head.sha || github.sha }}
path: mobile/artifacts/runtime-screenshots/*.png
if-no-files-found: ignore
retention-days: 14
- name: Build the ARM64 delivery APK
working-directory: mobile/android
run: ./gradlew --no-daemon -PtabbyAbis=arm64-v8a assembleDebug
Expand All @@ -259,6 +271,7 @@ jobs:
mobile/artifacts/Tabby-Android-Emulator.apk.sha256
mobile/artifacts/android-emulator-apk-verification.json
mobile/artifacts/android-runtime-report.json
mobile/artifacts/runtime-screenshots/*.png
mobile/artifacts/android-delivery-verification.json
if-no-files-found: error
retention-days: 14
89 changes: 77 additions & 12 deletions mobile/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Android SSH and tmux prototype
# Android mobile SSH candidate

This branch starts from desktop commit
`fb869900e1ac6da0ff577a53ab7084778d6389bc`. Mobile code is isolated here;
Expand All @@ -17,6 +17,46 @@ Mobile saved host-key identities use the native verified public-key blob;
desktop retains its existing algorithm/fingerprint representation. The shared
core does not define a cross-platform saved-identity import format.

## Candidate interaction and lifecycle

This product batch is based on design SHA `667663dbeb6b8439dd26471a3fa0be64ab814d7f`.
It preserves the device-tested prototype `ded417ba852295f3ad76bcca4e6e557d1288ca12`.
Home returns to the connection workbench without disconnecting. Phones switch
adjacent sessions by dragging only the central title; larger windows show scrolling
Tabs. Terminal swipes remain local history gestures. A single scrolling auxiliary
key row has 48dp targets, one-shot Ctrl/Alt and an accessible all-keys panel. Dragged
or cancelled key gestures send no bytes. Short landscape hides this row and retains
the 48dp genuine editor. Font controls use Android's nonlinear sp conversion and
resize the actual terminal/PTY cells; each pane retains its own size.

Background retention is off by default and starts only from the foreground with
an established connection. Android 13+ asks through the normal notification
permission dialog; denial keeps the foreground connection available. The product
requires visible notifications even though Android itself permits foreground
services without notification permission. The private `specialUse` service has a
generic notification and Stop All action, uses `START_NOT_STICKY`, and declares its
interactive SSH purpose. There is no boot receiver, battery-setting change, wake
lock or task rescheduling. This is a sideload candidate; specialUse distribution
review is outside this draft's scope.

Activity recreation cannot preserve the old WebView parser. On reopening, old
native streams close and saved tmux identity is offered for manual recovery;
remote tmux sessions are never killed. Background transport loss requires a
foreground return. Foreground tmux restoration allows at most six backoffs
(1/2/4/8/16/30 seconds) and 120 seconds elapsed; auth, host-key, account/session
identity or occupied-session checks pause it. Plain SSH requires an explicit new
shell. Input queued before backgrounding or a Tab switch is not replayed. The
existing native and renderer output limits remain bounded; overflow closes with
an error rather than silently dropping terminal output. Process death never
promises connection survival or complete output history.

Generated-key creation, encrypted persistent private keys and confirmed public-key
installation remain a later batch; this APK does not perform enrollment. Existing
plain/encrypted private-key imports are preserved. No real credentials are accepted
by this development task. Runtime images and tests use disposable test fixtures;
only emulator serials are accepted by the runner. Notification decisions use the
normal UI in fresh disposable emulators and never use `pm grant`.

## Supported scope

- Password, ordinary/encrypted OpenSSH private-key and keyboard-interactive
Expand All @@ -26,8 +66,9 @@ core does not define a cross-platform saved-identity import format.
- A real textarea for system IME composition; terminal rendering, touch scroll,
long-press text selection, native clipboard and Ctrl/Esc/Tab/arrow buttons.
- Viewport-driven PTY resize, connection cancellation, bounded output/input
queues and generation checks. Backgrounding closes all connections and
clears transient credentials. Each Tab owns its own native SSH transport.
queues and generation checks. Backgrounding clears transient credentials.
Connections close by default; an explicit visible foreground service can retain
established connections while switching apps. Each Tab owns its own native SSH transport.
- Optional tmux discovery on the authenticated account and chosen socket,
explicit session selection or atomic named creation, shared/read-only access,
and explicit takeover. Ordinary SSH remains available; missing tmux is never
Expand All @@ -44,7 +85,9 @@ core does not define a cross-platform saved-identity import format.
takeover do not start automatic recovery. Occupied automatic recovery pauses
without detaching another client. Private-key and keyboard-interactive sessions
require explicit credentials/import on recovery; passwords are only kept in
foreground memory. No background SSH or hard fencing is promised.
foreground memory unless optional native password storage is selected. Background
retention is best effort; system termination and network changes can disconnect.
No hard fencing is promised.

The configured minimum is Android 8 / API 26; compile/target SDK is 36. The
default application build targets ARM64. The cloud emulator build explicitly
Expand All @@ -55,7 +98,7 @@ bar/cutout or visible IME inset, including navigation space when the IME is
visible with zero height. Child WebView system-bar/cutout insets are cleared
after native padding to avoid applying them twice. Capacitor's automatic inset
handler is disabled; the final WebView layout still drives terminal/PTY resize.
Jump hosts, SSH agents, X11, host certificates and background SSH remain
Jump hosts, SSH agents, X11, host certificates and generated-key enrollment remain
outside this mobile prototype. Real exec control channels are separate from the
PTY terminal. A framed response is accepted only after native complete channel
close with an exit status; cancellation/time/output limits fail closed and do
Expand All @@ -71,7 +114,13 @@ window transitions and keyboard-height changes while preserving the session
and updating PTY dimensions; they do not establish a physical fold transition.
Record the device's installed Android/WebView/IME versions during acceptance.

Credentials are neither logged nor saved to profiles, localStorage or files.
Credentials are never logged or saved to profiles or Web storage. Optional saved
passwords use Android Keystore AES-256-GCM and app-private `noBackupFilesDir`,
bound to normalized host/port/account and the verified host-key blob. They are
written only after successful authentication. Failed authentication leaves the
previous entry intact; deletion cancels connections to that target before removing
the entry. Secret bytes are cleared where practical; temporary JVM/JNI strings
cannot be guaranteed to be zeroized.
Public host keys and explicitly saved tmux identities are persistent SSH data;
saved identity JSON never contains passwords, private keys, key handles or output. Private keys are bounded to
64 KiB, RSA keys to 8192 bits and encrypted OpenSSH bcrypt cost to 64 rounds;
Expand Down Expand Up @@ -162,12 +211,28 @@ input-event categories and focus booleans, never editor text. The history-render
gate also retains its last bounded geometry and a fixed synthetic-marker
visibility flag before the later swipe initializes its baseline. Its original
readiness predicate and deadline remain; no terminal contents are reported.
When SystemUI's clipboard preview is observed, the harness sends one normal
native outside touch to the passive app status label, using the existing
app-window, focus, visibility and stable-geometry guards. It then still requires
350ms of observed preview absence within the original 10-second phase budget.
This changes no SystemUI settings, permissions or IME configuration and does
not touch a foreign window, retry the gesture or synthesize DOM activation.
When SystemUI's clipboard preview is observed, the harness selects the normal
close button only inside the exact SystemUI clipboard container. If that control
is absent, it uses one guarded native outside touch to the app's More button.
The read-only hierarchy lists system windows because a clipboard preview need
not own the active window; control selection remains confined to that container.
See [AOSP's window hierarchy command](https://android.googlesource.com/platform/prebuilts/fullsdk/sources/+/88c7ff1cd72d6305ec59f97aadc2198cc2dc3592/android-34/com/android/commands/uiautomator/DumpCommand.java).
It still requires 350ms of observed preview absence within the original
10-second phase budget. Notification Stop similarly belongs to this app's exact
notification card; a collapsed card is normally expanded once before clicking
Stop. These bounded actions change no SystemUI settings, permissions or IME
configuration and never dismiss an ANR, security dialog or foreign notification.
Independent cloud WebView phases finish their owned instrumentation and restart
only the disposable app process, retaining native public pins and encrypted
fixture records. They cannot reuse a cached debug page from a destroyed WebView.
The actual lifecycle and network checks run within each phase; focus emulation
is disabled and the native focus gates remain mandatory on every new attach.
Rebuilt connection forms must also have native foreground focus and stable
controls before synthetic fixture entry, then retain exact endpoint readback
for 350ms before the one native submission. These observations remain inside
the existing harness budget; they neither repair input values nor replay failed
submissions. Failure diagnostics contain only field-validity/match booleans and
fixed per-field validation categories, never host, account or credential values.
See [Android's device-lock API](https://developer.android.com/reference/android/app/KeyguardManager#isDeviceLocked())
and [TrustManager's direct user dump](https://github.com/aosp-mirror/platform_frameworks_base/blob/master/services/core/java/com/android/server/trust/TrustManagerService.java).
The
Expand Down
4 changes: 2 additions & 2 deletions mobile/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ android {
applicationId = 'org.tabby.android.prototype'
minSdk = rootProject.ext.minSdkVersion
targetSdk = rootProject.ext.targetSdkVersion
versionCode = 1
versionName = '0.1.0-prototype'
versionCode = 2
versionName = '0.2.0-mobile-candidate'
testInstrumentationRunner = 'androidx.test.runner.AndroidJUnitRunner'
ndk { abiFilters.addAll(selectedAbis) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,14 @@ class CloudWebViewHarness {
val context = InstrumentationRegistry.getInstrumentation().targetContext
require(context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0)
val done = File(context.filesDir, "tabby-cloud-webview.done")
require(!args.containsKey("cloudReadyFile") || args.getString("cloudReadyFile") == "tabby-cloud-webview.ready")
val ready = args.getString("cloudReadyFile")?.let { File(context.filesDir, it) }
val input = File(context.filesDir, "tabby-cloud-input.json")
val result = File(context.filesDir, "tabby-cloud-input.result.json")
val resultTemporary = File(context.filesDir, "tabby-cloud-input.result.tmp")
val inputEnabled = args.getString("cloudInputFile") == "tabby-cloud-input.json"
done.delete()
ready?.delete()
input.delete()
result.delete()
resultTemporary.delete()
Expand All @@ -119,6 +122,9 @@ class CloudWebViewHarness {
WebView.setWebContentsDebuggingEnabled(true)
}
val deadline = System.nanoTime() + 180_000_000_000L
// Setup no longer erases an early command from the next owned
// harness. This signal grants no window or editor focus.
ready?.writeText("READY")
while (!done.isFile && System.nanoTime() < deadline) {
if (inputEnabled && input.isFile) {
var ok = false
Expand Down Expand Up @@ -210,6 +216,7 @@ class CloudWebViewHarness {
WebView.setWebContentsDebuggingEnabled(false)
}
done.delete()
ready?.delete()
input.delete()
result.delete()
resultTemporary.delete()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.tabby.android.prototype

import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import android.content.Context
import java.io.File

@RunWith(AndroidJUnit4::class)
class EncryptedSecretStoreTest {
@Test fun encryptedUpdateDeleteAndTamperFailClosed() {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = EncryptedSecretStore(context)
val host = "isolated-fixture.invalid"; val user = "synthetic-vault-test"; val pin = "fixture-public-pin"
val first = "SYNTHETIC_ONLY_FIRST_PASSWORD".toByteArray()
val second = "SYNTHETIC_ONLY_UPDATED_PASSWORD".toByteArray()
store.delete(host, 22, user)
try {
assertFalse(store.has(host, 22, user))
store.put(host, 22, user, pin, first)
assertArrayEquals(first, store.get(host, 22, user, pin))
assertNull(store.get(host, 23, user, pin))
assertNull(store.get(host, 22, user + "other", pin))
val files = File(context.noBackupFilesDir, "ssh-secrets-v1").listFiles()!!.filter { it.isFile }
assertTrue(files.isNotEmpty())
files.forEach { assertFalse(it.readText().contains(String(first))); assertFalse(it.name.contains(host)) }
var rejected = false
try { store.get(host, 22, user, "changed-pin") } catch (_: Throwable) { rejected = true }
assertTrue("changed host identity must reject saved password", rejected)
store.put(host, 22, user, pin, second)
assertArrayEquals(second, store.get(host, 22, user, pin))
store.delete(host, 22, user)
assertFalse(store.has(host, 22, user)); assertNull(store.get(host, 22, user, pin))
} finally { first.fill(0); second.fill(0); store.delete(host, 22, user) }
}
}
Loading
Loading