Android #
Native app for dev.pneumo.app: minSdk 26, target API 37, compile SDK 37.2,
Kotlin 2.x, Compose and Material 3. Dynamic color is off. The non-UI core mirrors
the frozen shared protocol and cryptography contracts.
One-time SDK setup on macOS #
Java comes from mise install; do not use the macOS Java stub.
The Homebrew command-line tools can be used without Android Studio or sudo:
brew install --cask android-commandlinetools
mkdir -p "$HOME/Library/Android"
# Only create this link if no SDK already exists at the destination.
ln -s "$(brew --prefix)/share/android-commandlinetools" "$HOME/Library/Android/sdk"
mise install
mise exec -- sdkmanager --sdk_root="$HOME/Library/Android/sdk" --install \
"platform-tools" "platforms;android-37.2" "build-tools;37.0.0" "emulator"
yes | mise exec -- sdkmanager --sdk_root="$HOME/Library/Android/sdk" --licenses
If command-line tools are already installed, skip the Homebrew installation.
For a separately installed SDK, put the command-line tools in
$HOME/Library/Android/sdk/cmdline-tools/latest instead of creating the link.
Stop if an installation asks for sudo or GUI consent.
sdkmanager currently emits a deprecation notice in favor of android sdk;
it remains the command used here to match the M0 setup contract.
Worktree setup and verification #
Run from the repository root:
mise install
mise run android:setup
mise run check:android
mise run e2e:android
mise exec -- apps/android/gradlew -p apps/android assembleDebug
android:setup can be repeated. It writes only the ignored
apps/android/local.properties, with the physical SDK path resolved from mise's
ANDROID_HOME. Resolving symlinks keeps Gradle's managed-device image lookup
consistent with the SDK package index. Spaces and colons are escaped as Java properties.
The Gradle wrapper and its distribution checksum are committed; no global Gradle
installation is needed. APK: app/build/outputs/apk/debug/app-debug.apk.
check:android runs Spotless/ktlint, detekt, Android Lint (warnings are errors),
JUnit4/Robolectric tests and Roborazzi verification. mise run check includes this
lane. JVM contracts cover every shared crypto vector (including the 600,000-
iteration KDF case once), all 90 valid and 111 invalid protocol fixtures, streaming
file validation, MockWebServer requests/errors and encrypted credential persistence.
Detekt uses the stable pair 1.23.8 with Compose Rules 0.4.23. The detekt config
builds on the default rules and enables Compose checks.
Lint's AndroidGradlePluginVersion, GradleDependency and NewerVersionAvailable
checks are disabled because upstream releases would otherwise make the gate
depend on the date rather than the pinned code. Other lint warnings remain
errors, and the lint baseline contains no suppressed issues. The older stable
Compose Rules version is a compatibility pin, not a lint incompatibility.
Core APIs #
crypto:MasterKey.derive,Hkdf.derive,deriveKeys,keyCheck,PushCrypto, streamingFileCrypto, strictPairingUriandUuidv7. Implementations use JCA/JCE andSecureRandom; wire formats follow SPEC §6. Enforce the passphrase minimum in the caller.FileCrypto.ciphertextSizecomputes the upload size. PBKDF2 derivation accepts iteration counts throughInt.MAX_VALUE, thePBEKeySpecAPI limit; protocol models retain the shared safe-integer range. File operations leave streams open. Discard all output if decryption fails: earlier authenticated chunks may already have been written.protocol: kotlinx.serialization models for every shared schema, including sealed payloads and WebSocket frames.ProtocolJsonignores unknown keys, including decrypted payload fields, but keeps required nullable fields required. Refinements validate canonical binary encoding, ids, timestamps, URLs and safe integer ranges. OnlyListPushesQuerycoerces numeric strings. HTTP(S) URL validation uses OkHttp to accept browser links with Unicode hosts or permissive path/query characters while preserving the original wire string. Whitespace and non-HTTP(S) URLs remain invalid.net.ApiClient: suspend APIs for account/pairing, devices, files and pushes. JSON reads run on the IO dispatcher. Errors exposeApiError.code,statusandmessage; every 401 isApiError.Unauthorized. Bearer credentials go only to authenticated origin requests or relative proxy transfers, never direct external transfers. Redirects are not followed. Download methods return an ownedResponseBody: consume it on an IO dispatcher and close it withuse.data.SecureStore.create(context):save,readandclearpersist or remove MK and token together. DataStore contains only authenticated ciphertext wrapped by the Android Keystore AES-256-GCM keypneumo_wrap, without user authentication. JVM callers inject aKeyWrapperthrough the constructor. Never log credentials, plaintext, pairing URIs or transfer URLs.
syncSharedFixtures copies the frozen shared files into
app/build/generated/sharedFixtures/crypto/vectors and
app/build/generated/sharedFixtures/protocol/fixtures. Generated resources are
wired into every host-test variant before resource processing and unit tests;
they are not committed.
Pairing and shell #
PneumoApp owns the lazy AppContainer. PairingViewModel exposes public screen
state only; pairing URIs, MK and tokens never enter navigation arguments or saved
UI state. CameraX with ZXing core scans QR codes without Google Play Services.
The alternative link field is masked and is not saved across recreation.
pneumo://pair intents and pasted/scanned links use the existing strict
PairingUri parser. Confirmation shows the server host before claiming with
Build.MODEL. The repository claims, fetches /api/me, compares the derived key
check in constant time, then seeds Room's lower bound from the own device's server
createdAt before saving verified credentials in SecureStore.
Post-claim retries reuse the claimed token rather than claiming a consumed code.
Application-scoped pending claims can be resumed by a replacement ViewModel;
a different code first removes the previous unverified device.
Restoration finishes before a new link is considered; an existing paired session
ignores incoming pairing links. Recreated and history launches do not replay VIEW
intents. Consumed action/data are replaced while preserving launch identity.
SessionPreferences stores the server origin, own device id and one-time
onboarding completion separately from encrypted credentials. Startup requires a
complete credential/metadata pair, then chooses Onboarding or Home. Notification
permission is requested on API 33+, followed by the battery-exemption request;
both may be skipped with warnings. Completion survives sign-out. Home shows encrypted
history and a text/file composer; Devices manages paired devices. Settings deletes this
device, stops the connection, and wipes credentials, session metadata, Room history,
and the sync cursor.
ScreenFrame owns safe-drawing padding inside its background Surface. The main
shell consumes Scaffold padding before applying any remaining safe-drawing insets,
so system bars are not counted twice and inset regions retain the canvas color.
The debug-only network security configuration permits cleartext only to exact
10.0.2.2 and localhost hosts. Release retains usesCleartextTraffic="false",
and confirmation warns about non-HTTPS origins outside debug builds.
The direct battery-exemption request required by SPEC §14.3 needs a narrowly
documented BatteryLife lint exception on that helper: this sideloaded,
no-FCM messaging app deliberately maintains its own connection. No lint baseline
entry or project-wide rule disable was added.
Connection and encrypted history #
ConnectionService is a sticky remoteMessaging foreground service. A complete
paired session starts it after pairing, on app restoration, after onboarding,
and from BOOT_COMPLETED or MY_PACKAGE_REPLACED. The silent connection
channel uses minimum importance and the brand notification icon; its ongoing
notification opens the app and reflects Connected, Reconnecting, or Offline.
Completing onboarding refreshes it after the notification-permission request.
Settings observes the same live StateFlow<ConnectionState>.
WsClient uses OkHttp with protocol pings disabled. It sends hello with the
current DataStore cursor on every attempt, answers application pings immediately,
and watches for 135 seconds without a frame. The mirrored protocol default is
DEFAULT_WS_PING_SECONDS = 60. Reconnect uses full jitter over 1, 2, 5, 10, 30,
then 60 seconds; 60 seconds Connected resets the sequence. Network recovery
preempts the delay. Close 1012 retries in 1–3 seconds; 4401 or an HTTP 401 during
resync stops retrying, wipes the local session, and returns to Pair.
Room v1 (pneumo.db, exported schema in app/schemas) stores encrypted push
envelopes, file metadata, devices, notification claims, and the durable server
ordering tuple. PushRepository decrypts on read with an in-memory content key.
It never persists plaintext or a decrypted-payload cache. Sign-out/revoke cancels
and joins the connection before wiping Room and DataStore.
SyncEngine applies frames serially, advances lastPushId after each applied
push, and deduplicates replay/live delivery by id. With a null cursor, ready
runs HTTP catch-up before queued live frames can move the lower bound past an
offline gap. Both this path and explicit resync page newest-first until a push
at/before the durable (createdAt, id) position, then apply missing pushes
oldest-first. With no durable position, an existing Room row is the fallback
overlap boundary. The initial position uses the server pairing
timestamp and an empty id, including pushes in that same millisecond without
fetching earlier history. Deleting the cursor push does not remove this bound.
Cursor ordering uses server time, not sender-generated UUID order. Heartbeats run
independently of ordered frame application and HTTP catch-up.
PushSink is implemented by PushNotifier. Only non-self pushes targeted to this
device or all devices are claimed. The durable notified claim precedes the sink
call, preventing duplicate calls across replay and restart. SQLite and an external
notification side effect cannot be atomic: process death between claim and sink
can lose that notification.
Push notifications #
The high-importance pushes channel renders notes with expanded text, links with
their URL, and files with their name, size and caption. Payloads are decrypted
only while building the notification; no plaintext history or payload cache is
written. Tagged notification ids and action identities remain distinct even when
push-id hashes collide. A group summary first appears at three visible children
and remains until the last child is removed: cancelling a summary in Android also
cancels its children. A synchronized, application-scoped set of push ids is seeded
from active notifications after process startup, then updated with known posts
and removals rather than asynchronous notification-service snapshots. Copy, Open,
body taps, dismissals and push_deleted all preserve the other unread children.
Copy uses an immutable, non-exported broadcast and cancels the push notification.
Only API 32 and earlier show a custom "Copied" toast. Open uses an immutable
activity intent with ACTION_VIEW and the HTTP(S) URL. The non-exported
OpenActivity cancels the notification before forwarding to the browser: Android
does not apply notification auto-cancel to action buttons. Its empty task affinity
and no-display theme keep Pneumo's existing task behind the previous screen, so
Back from the browser does not unexpectedly open Pneumo. The dispatcher rejects
non-HTTP(S) URLs; protocol payload validation already excludes them.
Download sends dev.pneumo.app.notify.DOWNLOAD to DownloadRequestedReceiver
with the pushId extra and enqueues DownloadWorker for the initiating session.
Completion replaces the same tagged child through PushNotificationGroup.post,
with Open and Share actions. Open cancels only that child before forwarding a
read-granted content URI; Share uses the non-exported, no-display
DownloadShareActivity to launch the system chooser and finish without cancelling
the child. Its immutable PendingIntent carries per-file URI/MIME identity, leaving
the chooser's data unset so it resolves and older notifications share their own file.
JVM regressions exercise separate Share targets on API 26 and 34. Body taps still pass
pushId through MainActivity, navigation and ShellUiState, selecting Home without
replaying the tab selection on recreation. Home fetches a missing target, scrolls
to it, and marks it selected.
Notification permission denial skips delivery without decrypting. Settings refreshes app and channel permission state on resume and offers "Open notification settings" when notifications are off.
Home, sharing, devices, and settings #
Home collects the newest-first Room history through PushRepository. Envelope
parsing, AES-GCM decryption, and payload decoding run on Dispatchers.Default,
not the Main collector; decrypted payloads stay in memory. HTTP pages extend the
encrypted cache without changing the connection's sync cursor. Notes, links, and
file metadata have distinct rows; files offer Download, then Open and Share.
Delete hides a row immediately, offers Undo for five seconds, then calls the API;
a failure restores the row. The composer retains failed drafts, detects links with
the protocol HTTP(S) rule, and offers all devices or an individual online/offline target.
The exported ShareActivity accepts ACTION_SEND text and EXTRA_STREAM files
of any MIME type, plus ACTION_SEND_MULTIPLE (one push per file). EXTRA_TEXT
supplies the body and EXTRA_SUBJECT the optional title. A single trimmed text URL
becomes a link; multiline prose stays a note. An optional message becomes the link
body or is appended to the note. With files, nonblank title/body/message accompany
only the first file; blank fields are omitted. When unpaired, the sheet opens the
existing pairing flow. The Home attach button uses ACTION_OPEN_DOCUMENT with
multiple selection; a file-only draft does not require message text.
WorkManager is pinned to 2.12.0. Sending encrypts before enqueueing, confirms the
enqueue within a 750 ms bound, and finishes the activity. WorkManager stores only
a spool-file identifier in its 10 KB Data; the private, no-backup spool contains
the encrypted server request and session device id, never plaintext or credentials.
The worker survives the activity finishing and writes the returned encrypted push
to Room. I/O failures during sending, including timeouts and DNS failures, and
HTTP 429 or 5xx responses retain the spool and return Result.retry(). There is no
application retry-attempt cap: WorkManager applies its backoff rather than silently
discarding shares during an outage. Retries reuse the stored id and encrypted body,
so the idempotent POST /api/pushes contract prevents duplicate creation.
Success and terminal failures delete the spool. Other 4xx responses, an unpaired
or mismatched session, and undecodable work are terminal. Session generation
checks prevent late responses from writing into a signed-out or newly paired cache.
SessionCache serializes each bounded HTTP cache operation with matching live
frames and session teardown. History/device reads and their writes share the same
domain lock, so a queued deletion or device update applies after an older HTTP
snapshot rather than being overwritten by it. Push and device domains remain
independent. The durable sync position, not a row inserted by history browsing,
defines catch-up's lower bound.
Devices supports rename, inline removal confirmation for other devices, and a Settings shortcut for this device's sign-out. Settings reuses the existing live connection component and notification recovery, refreshes notification and battery state on resume, and shows the server authority and app version.
File transfers #
UploadAttachments reads OpenableColumns name/size and the resolver MIME type,
falling back to a concrete intent MIME type, the extension, then
application/octet-stream. Before enabling Send, it snapshots each source into
app-private cache. This includes picker documents: snapshots keep the streamed
length and bytes stable across retries. Workers therefore do not depend on
temporary grants or retain persistable URI permissions.
These copies are the user's own selected plaintext, not decrypted received files.
Dismissal/removal deletes unqueued copies; confirmed enqueue transfers ownership
to the worker, which deletes them on success or terminal failure and retains them
on retry. Failed enqueue restores the draft's snapshots.
Upload work metadata is authenticated ciphertext wrapped with the existing
Keystore key in noBackupFilesDir; WorkManager receives only a random spool id.
UploadWorker compares FileCrypto.ciphertextSize against /api/me's
limits.maxFileBytes before reserving. Each new reservation gets a fresh file key
and push-envelope nonce, with the new key and envelope atomically persisted before
the streaming PUT. A failed PUT can therefore retry under a different file id
without reusing AES-GCM nonces with the same key and different file-id AAD.
After PUT succeeds, it persists the uploaded file id before POST, so a lost POST
response retries the identical full push request without another reservation,
file key, envelope, or PUT.
Work is expedited where quota allows and uses a dataSync foreground notification
on the low-importance transfers channel. Network I/O, 429 and 5xx retry with
WorkManager backoff; other 4xx, changed sessions, and unreadable spools/sources
fail terminally. Progress/fixed errors contain no payloads, credentials or keys.
The API 34+ FOREGROUND_SERVICE_DATA_SYNC permission is recorded in
ADR 0003 and SPEC §14.2.
Downloads occur only after an explicit action. DownloadWorker resolves the file
target, streams GET through FileCrypto, and writes only to
MediaStore.Downloads under Download/Pneumo/ on API 29+, using IS_PENDING until
authentication succeeds. API 26–28 uses the app-specific external
Download/Pneumo/ directory and a narrowly scoped FileProvider. Collisions append
(1), (2), etc. before the extension. Failures and cancellation delete the
partial output; invalid headers/tags/truncation show exactly
"This file was corrupted or tampered with". WorkManager records only ids, numeric
progress, the completed content URI, and fixed errors. Home observes the same
download state as the notification; its Open/Share use the decrypted payload MIME
type without persisting that payload.
Generic or wildcard MIME types use the saved display-name extension for Open/Share;
explicit MIME types take precedence. This lookup stays in memory and lets an
application/octet-stream PNG from dev-client open in an installed image viewer.
JVM worker tests use WorkManagerTestInitHelper and MockWebServer for exact
direct/proxy paths, upload round trips at 0 B, 1 B, 65,536 B and 1 MiB, fresh keys
and matching envelopes after failed PUTs, unchanged requests after lost POST
responses, and downloaded bytes. Download regressions require bulk destination
writes without scalar writes and exercise real API 28 collision-safe files.
Scheduler tests cover ciphertext limits before enqueue, omitted blank fields, and
first-file-only captions. Home tests cover file-only sends, retryable drafts,
snapshot replacement/removal, and ownership through enqueue and ViewModel cleanup.
Tampering/partial cleanup, network retry, and session mismatch remain covered.
androidx.work:work-testing is pinned to the existing WorkManager 2.12.0.
Screenshots #
ScreenPreviews supplies the same fixture data to Compose @Preview and
PneumoScreensScreenshotTest, using Robolectric API 34 native graphics, a fixed
360×800 dp viewport and xhdpi. Goldens live in app/src/test/screenshots/debug/.
The camera-granted fixture renders the framed preview using its default empty
cameraPreview slot; previews do not start a camera.
ENG-253 intentionally replaces the two scaffold goldens with 38 screen goldens.
Each base name below has both -light.png and -dark.png variants:
- Pair:
pair-idle,pair-camera-granted,pair-camera-denied,pair-paste-link,pair-invalid-error. - Confirm:
confirm-https,confirm-non-https,confirm-claiming,confirm-retryable-error,confirm-error. - Onboarding:
onboarding-notifications,onboarding-battery,onboarding-done,onboarding-warning. - Shell:
main-shell-home,main-shell-devices,main-shell-settings,main-shell-settings-busy,main-shell-sign-out-error.
ENG-259 adds six component goldens: connection-connected,
connection-reconnecting, and connection-offline, each in light and dark.
ConnectionStatusPreview and ConnectionStatusScreenshotTest share those state
fixtures. The light/dark main-shell-settings, main-shell-settings-busy, and
main-shell-sign-out-error goldens were intentionally re-recorded for the live
status row.
ENG-263 adds the light/dark main-shell-settings-notifications-off goldens, shared
with the notifications-off Compose preview. Existing screen goldens are unchanged.
ENG-264 adds 66 light/dark goldens for Home, composer, Share, Devices, and all four
notification/battery permission combinations in Settings. Separate note, link, and
file fixtures keep every row type visible. Existing shell goldens were deliberately
re-recorded for the real destinations: main-shell-home, main-shell-devices,
main-shell-settings, main-shell-settings-busy,
main-shell-settings-notifications-off, and main-shell-sign-out-error, each with
-light.png and -dark.png. Pair, Confirm, Onboarding, and connection goldens are
unchanged.
ENG-266 adds 24 light/dark goldens: composer files/preparation, upload progress/error,
download progress/done/error, file sharing, and all four transfer-notification
states. Notification previews render Android's real expanded RemoteViews templates
on their standalone framework light surface; SystemUI owns the runtime shade
palette. The 24 existing Home goldens, main-shell-home, share-invalid, and
share-not-paired (each light/dark) are deliberately re-recorded for attachment
controls, file actions, and the updated file-capable wording. Other goldens are
unchanged.
Only record when intentionally changing the expected UI:
mise exec -- apps/android/gradlew -p apps/android recordRoborazziDebug
mise run check:android
Review the image changes and say that baselines were recorded in the commit
message. Roborazzi's variant-specific directories are enabled because Gradle 9
requires tasks not to share screenshot output directories. Comparison reports
and lint/JUnit reports stay under build/.
Managed-device smoke test #
mise run e2e:android calls scripts/e2e/android.ts, which builds the server,
starts isolated MinIO and server instances on random host ports, then runs
pixel6Api34DebugAndroidTest: Pixel 6, API 34, aosp-atd, with no Google Play
Services dependency. Uploads use proxy mode so the emulator never needs to reach
MinIO's host-only endpoint. PUBLIC_URL uses 10.0.2.2; startup waits for the
structured Server listening event and health response. The MinIO helper supports
Docker or PNEUMO_E2E_MINIO=native with minio/mc on PATH.
Gradle downloads the matching system image and creates/boots/stops the device.
Hardware virtualization must be available. Stop other emulators first and run only
one managed-device suite at a time on this Mac. The existing 14 tests remain
independent of the real-server harness. They cover initial Pair, valid and invalid
deep-link routing, selected
Settings-tab restoration, cold/warm notification navigation, and the denied
notification permission state. The SecureStore smoke uses the real Android
Keystore, including reopening the wrapper and clearing both credentials.
HomeShareSmokeTest also visits every bottom-navigation destination, launches a
text-share intent, waits for the sheet to finish, and verifies the decrypted push
in Home. Its isolated loopback HTTP server receives real encrypted envelopes;
no external server, credentials, or new test dependencies are required.
A second share case returns HTTP 503 for the first POST. It verifies the real
WorkManager retry after the sheet finishes and checks that the identical encrypted
request reaches Home once. JVM tests also cover retryable versus terminal errors
and collect history on Main while checking that actual cipher key access occurs
on a different thread.
FileTransferSmokeTest shares a granted content URI, lets the real upload worker
finish after the sheet closes, invokes the posted notification's Download action,
compares the saved bytes, and verifies Open/Share replace the same notification id.
The loopback fixture handles binary bodies and closed idle connections.
Notification-posting cases live under notify, following the existing
notification tests after the shell's denied-permission case. Revoking an already
granted runtime permission would kill the shared instrumentation process.
Turbine/JVM tests additionally cover key mismatch deletion/wipe, expired codes, consumed-code retries, replacement ViewModels, sign-out and onboarding persistence. QR analyzer tests decode padded luminance frames and verify frame ownership on unreadable input. Robolectric lifecycle cases cover consumed-link recreation, saved task bundles, history launches and stored-session precedence over cold and warm foreign links.
PushNotificationGroupTest posts three pushes back-to-back against the managed
device's real notification service. It verifies that Copy and a system-style
dismissal leave both unread children posted, that a downloaded-file replacement
preserves all three children, and that the summary survives until the last child
is removed. Each expected state must remain stable while queued
notification-service operations settle. Robolectric does not model Android's
summary-cancellation cascade, so it cannot replace this device regression.
Connection tests cover hello cursors, ordered replay/live dedupe, resync overlap, device/deletion frames, notification eligibility, unauthorized sign-out, jitter and reset, watchdog silence, network recovery, Room DAOs, decrypt-on-read, null-cursor reconnects before the first live push, deleted-cursor resync with empty history, and cursor recovery across the DataStore/Room write boundary. Boot/service Robolectric tests cover paired/unpaired restoration and the silent minimum-importance channel.
Real-server flow and coordination #
RealServerSmokeTest adds one ordered server-backed case:
- Deliver the real
pneumo://pairVIEW intent, confirm the host, pair, grant notifications withGrantPermissionRule, and skip the battery exemption. - Wait for Settings to show Connected.
- Send an encrypted note with the sender session's production
ApiClientand crypto, invoke notification Copy, and read the exact clipboard text from the foregroundMainActivity. No debug-only clipboard probe is used. - Send a link, verify Open/Copy, and intercept the forwarded browsable
ACTION_VIEWURL with Espresso Intents; ATD has no browser. - Send a random 1 MiB encrypted file, invoke Download, compare MediaStore length and SHA-256, and require the same tagged notification to expose Open/Share. Invoke its real Share PendingIntent and verify a resolvable, data-free chooser with that file's stream URI, MIME type, clip data, and read grant.
- Type and send a Home-composer note. Before clicking Send, the test calls the
harness's
/send-back-readyendpoint. The harness startspnpm --silent dev-client wait-forand waits until that CLI device is online inGET /api/devices, proving its hello was authenticated before Send./send-back-receivedchecks the waiter's exit code, body, type, and source. This HTTP handshake avoids ENG-275's live-only waiter race without changing the dev client. - Press Home. The harness enables deep idle (disabled by default on ATD),
unplugs the simulated battery, forces Doze and verifies
get deepisIDLE, sends a note with the dev client's productionClient, then unforces idle, resets the battery, and wakes the device. The test requires its notification within 30 seconds.
The test attempts UiDevice.openNotification() and package-scoped SystemUI title
and action selectors. This ATD has no interactive notification shade: the exercised
alternative invokes the real posted action's PendingIntent, selected by push tag,
title, and action. Progress reports explicitly distinguish shade clicks from
ATD PendingIntent actions. Selectors must not match Home's identical text.
Only the non-secret pneumoHarness coordinator URL goes through a Gradle
-Pandroid.testInstrumentationRunnerArguments.* argument. The harness creates
temporary mode-0600 CLI sessions, captures pair-uri privately, and sends the
pair URI and sender session over adb stdin into an atomic, mode-0600 app-private
files/pneumo-e2e.json. The test consumes/deletes that file. It delivers the
pairing VIEW through Instrumentation.callActivityOnNewIntent after launching
MainActivity, avoiding credential-bearing ActivityTaskManager launch diagnostics.
The existing fake-link tests retain cold-launch routing coverage.
Gradle/server output, captured logcat, instrumentation XML/logcat, and test reports
are scanned for the live pairing URIs, tokens, MK, setup token, and passphrase.
Credential matches fail the run and are redacted from the ignored
apps/android/build/e2e/last-run.log. Expected private pair-uri stdout is not
included in diagnostic output. Teardown stops the owned processes/device and
MinIO, restores Doze/battery state, and removes temporary sessions; Gradle's
concurrent device shutdown is treated as an already-completed teardown.
Running ./gradlew pixel6Api34DebugAndroidTest without harness arguments skips
only the new real-server case. Test-only dependencies are pinned directly in
app/build.gradle.kts: UiAutomator 2.3.0 and Espresso Intents 3.7.0. Espresso
Intents already supplies AndroidX Test Rules 1.7.0 for GrantPermissionRule.
The two scoped UseTomlInstead suppressions preserve the issue's explicit
restriction against editing the shared version catalog.
ENG-268 transfer-notification regression: this smoke found an ENG-266 bug: the 1 MiB download generated 103 foreground dispatches in 1.148 seconds (101 in one wall-clock second), and Android shed notification enqueues. The final Open/Share replacement was lost even though the downloaded bytes were correct. The tagged downloading and terminal posts can each also enqueue a group summary.
Uploads and downloads share one process-wide TransferProgressThrottle.Gate.
Its synchronized clock check admits at most one changed-percentage progress
update per second across all transfer instances. WorkManager can enqueue twice
for a download foreground update (notify plus startForeground), so a
per-transfer 500 ms interval was insufficient for overlapping downloads.
Percentage deduplication remains per transfer; tests inject a shared gate and clock.
Initial foreground notifications and terminal download complete/failure/tamper posts bypass the gate. WorkManager's persisted progress, notification ids/tags, and the existing group-summary rules are unchanged. The throttle schedules no deferred posts that could overwrite a terminal state.
JVM tests interleave two transfers to check their combined one-second cadence, cover repeated/rejected percentages, and verify visible upload progress. A download regression records the worker's actual foreground-update calls during real file decryption with a virtual clock. It was observed failing with the round-1 unthrottled callback and passing after restoring the gate; it also checks authenticated bytes and terminal Open/Share while the progress gate is closed. Success, tamper, and non-retryable HTTP terminal actions remain covered.
The review-round managed-device smoke passes all 15 tests, including the original Open/Share assertion, with zero credential matches and successful teardown. No Android rate-limit setting or end-to-end fixture speed was changed.
Earlier regular-emulator acceptance #
ENG-259's local-server
check used a regular API 34 emulator, deep-link pairing, and the onboarding battery
exemption. Settings reached Connected; package replacement restored the socket.
During ten minutes of screen-off forced Doze, all ten one-minute samples remained
online with a Connected foreground notification. After deviceidle unforce,
Room held all 12 test pushes (two before Doze, ten during), each marked notified.
The emulator state, isolated server/MinIO, and throwaway harness were cleaned up.
Android 14 elevated the active foreground-service channel to LOW (2) from its
requested MIN (1); its original importance remained MIN and sound stayed off.
ENG-264's isolated regular API 34 Google APIs emulator acceptance paired by deep
link, sent https://example.com through the exported share sheet, and observed a
link in dev-client listen after 631 ms on the final APK. Home displayed the link;
a multiline composer note and live delete/undo were also exercised. Light/dark and
font-scale 1.3 captures are saved under ignored apps/android/build/acceptance/,
including share-url-final.png, home-link-final.png, and
home-note-link-dark-large-text.png. The emulator, isolated random-port server,
MinIO, temporary sessions, and temporary AVD were stopped or removed afterwards.
ENG-266's isolated API 34 Google APIs emulator paired by deep link with a
random-port server and MinIO. Sharing a PNG from Photos through the system share
sheet produced a byte-identical dev-client download; the Home system document
picker also sent two files without requiring message text. In the reverse
direction, dev-client send file produced a notification whose Download action
saved identical bytes; Open launched Photos, and in-app Share opened the system
chooser. A repeat download produced image (1).png. SHA-256 in both directions:
786a609e335e69e5408f236dcbf75d1d7d8e13f7d14efb2bca3842117b80560c.
Ignored captures under apps/android/build/acceptance/ include
share-image-ready.png, notification-download.png,
notification-open-share.png, download-opened.png,
download-share-chooser.png, and composer-multiple-ready.png.
The emulator, isolated server/MinIO, temporary sessions, AVD, and harness were
stopped or removed after acceptance.
ATDs disable drawing by default and omit Settings/SystemUI. For a manual screenshot
smoke, enable drawing with adb shell setprop debug.hwui.drawing_enabled 1 and
restart the app; restore it to 0 afterward. Missing battery settings produce an
explicit warning and allow onboarding to finish. Physical-camera scanning and real
system-bar appearance still need a regular emulator or phone.