Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/alpha-feedback.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ body:
id: version
attributes:
label: Threadline version
description: Use the version shown in Diagnostics, for example 0.1.0-alpha.3 (10003).
description: Copy the version name and version code shown in Diagnostics.
validations:
required: true
- type: input
Expand Down
55 changes: 49 additions & 6 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,66 @@ permissions:
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 45
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: "17"
- uses: android-actions/setup-android@v4
- uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
- name: Install Android SDK platform
run: sdkmanager "platforms;android-37.0" "build-tools;36.0.0"
- uses: gradle/actions/setup-gradle@v6
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-provider: basic
- name: Test, lint, and assemble debug and release
run: ./gradlew --no-daemon test lint assembleDebug assembleRelease
- name: Verify release JNI field names
run: ./scripts/verify-release-jni-contract.sh
- name: Install emulator system image
run: sdkmanager "emulator" "system-images;android-35;google_apis;x86_64"
- name: Create emulator
env:
ANDROID_AVD_HOME: ${{ runner.temp }}/threadline-avd
run: |
mkdir -p "${ANDROID_AVD_HOME}"
echo "no" | avdmanager create avd --force --name threadline-ci --package "system-images;android-35;google_apis;x86_64"
"${ANDROID_HOME}/emulator/emulator" -list-avds | grep -Fx threadline-ci
- name: Start emulator
shell: bash
env:
ANDROID_AVD_HOME: ${{ runner.temp }}/threadline-avd
run: |
sudo chmod 666 /dev/kvm
emulator_log="${RUNNER_TEMP}/threadline-emulator.log"
nohup "${ANDROID_HOME}/emulator/emulator" \
-avd threadline-ci \
-no-window \
-noaudio \
-no-boot-anim \
-no-metrics \
-no-snapshot \
-gpu swiftshader_indirect \
> "${emulator_log}" 2>&1 &
if ! timeout 300 adb wait-for-device; then
cat "${emulator_log}"
exit 1
fi
if ! timeout 300 bash -c 'until [[ "$(adb shell getprop sys.boot_completed | tr -d "\r")" == "1" ]]; do sleep 2; done'; then
cat "${emulator_log}"
exit 1
fi
adb shell settings put global window_animation_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global animator_duration_scale 0
- name: Run instrumented tests
run: ./gradlew --no-daemon connectedDebugAndroidTest
- name: Stop emulator
if: always()
run: adb emu kill || true
- name: Package unsigned alpha candidate
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
id: alpha-candidate
Expand Down Expand Up @@ -66,7 +109,7 @@ jobs:
} >> "${GITHUB_OUTPUT}"
- name: Upload unsigned alpha candidate
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.alpha-candidate.outputs.name }}
path: ${{ steps.alpha-candidate.outputs.path }}
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ For a change:
5. Do not include passwords, private keys, `.env`, host data, or raw packet
logs in commits or bug reports.

Gradle dependency checksums are committed in
`gradle/verification-metadata.xml`. When a dependency or plugin changes,
regenerate the affected entries with Gradle's
`--write-verification-metadata sha256` option and review the resulting checksum
changes before committing them.

SFTP, port forwarding, cloud services, analytics, AI features, and
multi-session work remain out of the current MVP unless the user explicitly
changes the product scope. Deliberately deferred work belongs in
Expand Down
17 changes: 11 additions & 6 deletions PROJECT_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,15 @@ and it can be reopened from Help. It is not an account setup flow or a multi-pag
After the introduction, the user sees saved host profiles and a blank connection form. Only the
standard SSH port `22` is prefilled; development-fixture values are never production defaults.

A host contains:
A saved host profile contains:

- Display name
- Hostname or IP address
- Port, default `22`
- Username
- Authentication method

Authentication method is selected for each connection and is not linked to the
saved profile. Credentials are never part of a host profile.

Supported authentication for MVP:

Expand Down Expand Up @@ -1043,7 +1045,7 @@ Exit criterion:

> Ten real users can perform small remote tasks for two weeks and provide useful product feedback.

Implementation status (2026-08-03): the accessibility and
Implementation status (2026-08-10): the accessibility and
connection/authentication-error pass is implemented, including typed network
failures, recovery actions, screen-reader semantics, and 200% font-scale action
reachability. Production-path large-output profiling now covers styled Unicode
Expand Down Expand Up @@ -1078,9 +1080,12 @@ from their decryption secret. Independent restores verified both copies against
the established key identity, closing backup recovery. Alpha.3 imported-key
authentication and default diagnostic checks subsequently passed on the
physical release artifact after force-stop/reopen. Preserving that encrypted
key through the next signed version update and the
limited-versus-public distribution decision remain open. Because the
repository is public, any published GitHub prerelease is public.
key through the next signed version update was then proven when permanent-key
alpha.4 installed over alpha.3 with local state intact and authenticated with
the retained key without re-import. Alpha distribution is direct invited
sharing while Phase 5 is open. Because the repository is public, any published
GitHub prerelease would be public. Sufficient invited technical-alpha use is the
remaining Phase 5 boundary.

---

Expand Down
37 changes: 29 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,26 @@ Threadline is an exploratory, transcript-first SSH client for Android. The
product idea is that commands should feel like messages and output should feel
like responses, while a real terminal remains underneath for interactive work.

**[Phase 5 — Alpha polish](docs/STATUS.md) is in progress.** Phase 4 security and persistence is
implemented. The app opens on a
## Alpha status

**[Phase 5: Alpha polish](docs/STATUS.md) is in progress.** Its feature slices
and owner-device acceptance through alpha.4 are complete. The remaining product
boundary is enough invited technical-alpha use to evaluate whether ten real
users can complete small remote tasks for two weeks and provide useful feedback.

The current accepted signed build is `0.1.0-alpha.4`; the next source candidate
is `0.1.0-alpha.5`. Signed builds are shared directly with invited testers.
There is no public signed APK or GitHub prerelease while Phase 5 is open.
Testers should obtain the APK and its checksum from the owner through the agreed
private channel, then follow the
[technical-alpha guide](docs/alpha-testing.md).
Alpha.5 must pass the signed update and physical-device regression path before
it replaces alpha.4 for testers.

Threadline has no supported production release. Do not use the alpha for
privileged or sensitive systems.

Phase 4 security and persistence is implemented. The app opens on a
deliberately plain command transcript with a saved multiline composer,
streaming command cards, bounded ANSI-aware output, lifecycle status,
interactive-terminal suggestions, and one-tap access to the same persistent raw
Expand Down Expand Up @@ -237,12 +255,14 @@ risks are recorded in

## Project status

Phases 0 through 4 are complete. **Phase 5 Alpha polish is in progress.** Its
Phases 0 through 4 are complete. **Phase 5: Alpha polish is in progress.** Its
accessibility/error, large-output performance, Samsung physical-validation, and
basic-onboarding slices are implemented. The installed-update preservation
check is complete. A distribution decision and sufficient technical-alpha use
remain; additional device and OEM coverage, including Pixel, is opportunistic
alpha evidence rather than a separate release gate.
check is complete. Direct invited sharing is the selected alpha distribution
path, with no public prerelease while Phase 5 remains open. Sufficient
technical-alpha use is the remaining boundary. Additional device and OEM
coverage, including Pixel, is opportunistic alpha evidence rather than a
separate release gate.

Alpha packaging preparation now defines the permanent release/debug identities,
explicit alpha versioning, interactive off-repository key creation, local
Expand All @@ -261,11 +281,12 @@ release/JNI blocker is closed. Two encrypted off-machine signing-key backups
now exist with separately held decryption material, and independent restores
verified both copies against the established key identity. Alpha.3 imported-key
authentication and default diagnostic checks now pass on the physical release
artifact. Public CI now produces source-bound unsigned candidates while the
artifact. Public CI now produces source-identified unsigned candidates while the
permanent signing key remains local. The permanent-key `0.1.0-alpha.4` artifact
installed over alpha.3 in place, retained onboarding, profiles, trusted hosts,
transcripts, settings, and the encrypted saved key, then authenticated with that
key without re-import. The distribution decision and technical-alpha use remain.
key without re-import. Alpha.5 signed-update acceptance is the next operational
check; invited technical-alpha use remains the product-validation boundary.

Use these records according to their purpose:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,11 @@ class ConnectionFormRetentionTest {
}
}

compose.onNodeWithTag(ConnectionFormTags.PRIVATE_KEY_AUTH).performClick()
compose.onNodeWithTag(ConnectionFormTags.PRIVATE_KEY_AUTH)
.performScrollTo()
.performClick()
compose.onNodeWithTag(ConnectionFormTags.KEY_PASSPHRASE)
.performScrollTo()
.performTextReplacement("session-only")

compose.runOnIdle { formVisible.value = false }
Expand Down Expand Up @@ -315,9 +318,14 @@ class ConnectionFormRetentionTest {
}
}

compose.onNodeWithTag(ConnectionFormTags.PRIVATE_KEY_AUTH).performClick()
compose.onNodeWithTag(ConnectionFormTags.SAVED_KEY_PREFIX + savedKey.id).performClick()
compose.onNodeWithTag(ConnectionFormTags.PRIVATE_KEY_AUTH)
.performScrollTo()
.performClick()
compose.onNodeWithTag(ConnectionFormTags.SAVED_KEY_PREFIX + savedKey.id)
.performScrollTo()
.performClick()
compose.onNodeWithTag(ConnectionFormTags.KEY_PASSPHRASE)
.performScrollTo()
.performTextReplacement("session-only")
compose.onNodeWithTag(ConnectionFormTags.CONNECT)
.performScrollTo()
Expand Down Expand Up @@ -361,18 +369,27 @@ class ConnectionFormRetentionTest {
}
}

compose.onNodeWithTag(ConnectionFormTags.PRIVATE_KEY_AUTH).performClick()
compose.onNodeWithTag(ConnectionFormTags.RENAME_KEY_PREFIX + savedKey.id).performClick()
compose.onNodeWithTag(ConnectionFormTags.PRIVATE_KEY_AUTH)
.performScrollTo()
.performClick()
compose.onNodeWithTag(ConnectionFormTags.RENAME_KEY_PREFIX + savedKey.id)
.performScrollTo()
.performClick()
compose.onNodeWithTag(ConnectionFormTags.RENAME_KEY_NAME)
.performTextReplacement("New name")
compose.onNodeWithTag(ConnectionFormTags.CONFIRM_RENAME_KEY).performClick()
compose.waitForIdle()
assertEquals(savedKey.id to "New name", renamed)

compose.onNodeWithTag(ConnectionFormTags.SAVED_KEY_PREFIX + savedKey.id).performClick()
compose.onNodeWithTag(ConnectionFormTags.SAVED_KEY_PREFIX + savedKey.id)
.performScrollTo()
.performClick()
compose.onNodeWithTag(ConnectionFormTags.KEY_PASSPHRASE)
.performScrollTo()
.performTextReplacement("delete-me")
compose.onNodeWithTag(ConnectionFormTags.DELETE_KEY_PREFIX + savedKey.id).performClick()
compose.onNodeWithTag(ConnectionFormTags.DELETE_KEY_PREFIX + savedKey.id)
.performScrollTo()
.performClick()
assertNull(deletedId)
compose.onNodeWithText("Delete saved key?").assertExists()
compose.onAllNodesWithText("ssh-ed25519 · SHA256:managed-fixture")
Expand Down Expand Up @@ -428,15 +445,20 @@ class ConnectionFormRetentionTest {
}
}

compose.onNodeWithTag(ConnectionFormTags.SAVE_PROFILE).performClick()
compose.onNodeWithTag(ConnectionFormTags.SAVE_PROFILE)
.performScrollTo()
.performClick()
compose.waitForIdle()
assertEquals("Local fixture", savedProfile?.displayName)
compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + original.id)
.assertIsSelected()

compose.onNodeWithTag(ConnectionFormTags.PASSWORD)
.performScrollTo()
.performTextReplacement("session-only")
compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + original.id).performClick()
compose.onNodeWithTag(ConnectionFormTags.SAVED_PROFILE_PREFIX + original.id)
.performScrollTo()
.performClick()
compose.onNodeWithTag(ConnectionFormTags.DISPLAY_NAME)
.assertEditableTextEquals("Lab")
compose.onNodeWithTag(ConnectionFormTags.HOSTNAME)
Expand All @@ -449,16 +471,22 @@ class ConnectionFormRetentionTest {
.assertEditableTextEquals("")

compose.onNodeWithTag(ConnectionFormTags.DISPLAY_NAME)
.performScrollTo()
.performTextReplacement(" Renamed lab ")
compose.onNodeWithTag(ConnectionFormTags.UPDATE_PROFILE).performClick()
compose.onNodeWithTag(ConnectionFormTags.UPDATE_PROFILE)
.performScrollTo()
.performClick()
compose.waitForIdle()
assertEquals(original.id, updatedProfile?.first)
assertEquals("Renamed lab", updatedProfile?.second?.displayName)
assertEquals(HostEndpoint("lab.example", 2200), updatedProfile?.second?.endpoint)

compose.onNodeWithTag(ConnectionFormTags.PASSWORD)
.performScrollTo()
.performTextReplacement("delete-me")
compose.onNodeWithTag(ConnectionFormTags.DELETE_PROFILE_PREFIX + original.id).performClick()
compose.onNodeWithTag(ConnectionFormTags.DELETE_PROFILE_PREFIX + original.id)
.performScrollTo()
.performClick()
assertNull(deletedId)
compose.onNodeWithText("Delete saved profile?").assertExists()
compose.onAllNodesWithText("operator@lab.example:2200").assertCountEquals(2)
Expand Down Expand Up @@ -511,6 +539,7 @@ class ConnectionFormRetentionTest {

compose.onNodeWithText("To replace this trust record", substring = true).assertExists()
compose.onNodeWithTag(ConnectionFormTags.DELETE_TRUST_PREFIX + trustedHost.endpointKey)
.performScrollTo()
.performClick()
assertNull(deletedEndpointKey)
compose.onNodeWithText("Forget trusted server?").assertExists()
Expand Down
13 changes: 7 additions & 6 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,12 +174,13 @@ product behavior.

**Status:** Deferred product design; not required for the current MVP or Phase 5.

The current host profile intentionally stores display name, endpoint, username,
and authentication mode without credentials. Before adding a startup directory,
shell preference, or keep-active preference, define validation, failure behavior,
profile migration, and whether each setting affects only a new connection or an
already active session. Keep-active behavior must also agree with the foreground
service and explicit disconnect contract.
The current host profile intentionally stores display name, endpoint, and
username without an authentication mode or credentials. Before adding an
authentication mode, startup directory, shell preference, or keep-active
preference, define validation, failure behavior, profile migration, and whether
each setting affects only a new connection or an already active session.
Keep-active behavior must also agree with the foreground service and explicit
disconnect contract.

## Generated device keys

Expand Down
6 changes: 3 additions & 3 deletions docs/HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,12 @@ AES-256-encrypted archives, and independent downloads, extractions, and key-iden
for both provider copies, closing backup recovery.
The permanent alpha.3 then authenticated with an encrypted saved fixture key before and after a
force-stop/reopen cycle, and its default diagnostic preview matched the documented privacy boundary.
Public CI then began producing source-bound unsigned candidates while permanent signing stayed
Public CI then began producing source-identified unsigned candidates while permanent signing stayed
local. The permanent alpha.4 installed over alpha.3 as the same app on the Galaxy S25 Ultra. Its
onboarding state, profile, trusted host, transcript history, settings, and encrypted imported key
survived, and the retained key authenticated and completed `pwd` without re-import. This closed the
installed-update preservation boundary. The limited-versus-public distribution decision and
technical-alpha use remain.
installed-update preservation boundary. Direct invited sharing was selected for the open Phase 5
alpha, with no public prerelease until the remaining technical-alpha use boundary is evaluated.
Additional device and OEM coverage is opportunistic alpha evidence rather than a separate Pixel
gate. See
[STATUS.md](STATUS.md) rather than this chronology for the active boundary.
Loading