Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
575 lines
36 KiB
Markdown
575 lines
36 KiB
Markdown
# Mobile Troubleshooting
|
|
|
|
## Android stays on “Syncing the latest data” after pairing
|
|
|
|
- **Symptom:** Device pairing and the direct DeviceLink handshake succeed, but
|
|
the mobile App remains on **Syncing the latest data**. The native log repeats
|
|
`Agent live stream rejected`, often once per retry interval.
|
|
- **Quick checks:** Correlate the pairing ID and workspace across the phone and
|
|
computer logs. If DeviceLink reaches its connected stage and the Agent live
|
|
rejection reports `protocol_revision_mismatch`, compare the safe
|
|
`expectedRevision` and `receivedRevision` hashes. This is a protocol
|
|
compatibility failure, not a local-network or pairing failure.
|
|
- **Root cause:** The computer rejects an Agent live subscription whose protocol
|
|
revision does not match its own. If the native rejection is collapsed into a
|
|
generic disconnect, the live lane treats a deterministic incompatibility as
|
|
transient and retries forever. Repeated disconnect callbacks can also keep
|
|
replacing the connection-ready deadline, so the App never reaches a visible
|
|
failed state.
|
|
- **Fix:** Preserve the typed rejection reason and revision hashes through the
|
|
native bridge. Classify `protocol_revision_mismatch` as terminal for the
|
|
current connection attempt, close the rejected subscription without
|
|
scheduling another live retry, and keep the original synchronization
|
|
deadline for retryable transport failures. Present an explicit incompatible
|
|
version message with a mobile update action; do not automatically restart a
|
|
terminal attempt after foreground resume.
|
|
- **Validation:** Verify an ordinary stream close still retries and rebuilds
|
|
DeviceLink after the recovery grace period. Inject a protocol-revision
|
|
rejection and assert that the connection enters the failed state once, emits
|
|
a `device_connection.phase_changed` diagnostic with stable
|
|
`protocol_revision_mismatch`, preserves both revision hashes, schedules no
|
|
additional subscription, and stays terminal across background and foreground
|
|
transitions. Confirm the failure UI offers **Check for updates** instead of
|
|
**Reconnect**.
|
|
- **References:** `apps/mobile/src/native/agentLiveNativeBridge.ts`,
|
|
`apps/mobile/src/services/workspaceAgentLiveLane.ts`,
|
|
`apps/mobile/src/services/mobileApplicationService.ts`,
|
|
`apps/mobile/src/components/MobileConnectionRecoveryOverlay.tsx`
|
|
|
|
## Android QR scan closes without advancing pairing
|
|
|
|
- **Symptom:** The pairing scanner opens, reads a valid Desktop QR code, and
|
|
returns to the device page, but the App neither shows an error nor advances
|
|
to waiting or confirmed state.
|
|
- **Quick checks:** Confirm Android launches and finishes the ZXing
|
|
`CaptureActivity`, then inspect events from `TuttiAppLifecycle`. A background
|
|
event during the in-process Activity handoff identifies a lifecycle-adapter
|
|
regression rather than a camera or QR parsing failure.
|
|
- **Root cause:** Android presents the scanner as a child Activity. Pausing the
|
|
React Activity therefore looks like ordinary App backgrounding. If the
|
|
service invalidates every pairing generation on that transition, the native
|
|
scan promise can resolve successfully while its QR payload is silently
|
|
discarded as stale.
|
|
- **Fix:** Own application visibility at the native platform boundary. Android
|
|
publishes `ProcessLifecycleOwner` state, so switching between Activities in
|
|
the same process remains foreground; iOS publishes `UIApplication`
|
|
foreground/background notifications. The TypeScript lifecycle port consumes
|
|
this one semantic signal, while pairing remains unaware of Activities and
|
|
scanners. A genuine process background suspends remote pairing operations
|
|
and starts the DeviceLink grace period. A claim already sent is reconciled
|
|
from challenge state after foreground recovery instead of being submitted
|
|
twice.
|
|
- **Validation:** Verify the lifecycle adapter publishes its initial state,
|
|
deduplicates transitions, and disposes subscriptions. Verify the application
|
|
service applies background policy only after a real process background event.
|
|
Verify scanner cancellation, duplicate taps, permission failures, and
|
|
claim/poll reconciliation in the device service. Finally scan on Android and
|
|
confirm no background event is emitted during the
|
|
`MainActivity -> CaptureActivity -> MainActivity` sequence.
|
|
- **References:**
|
|
`apps/mobile/android/app/src/main/java/sh/tutti/mobile/AppLifecycleModule.kt`,
|
|
`apps/mobile/ios/TuttiMobile/AppLifecycleModule.swift`,
|
|
`apps/mobile/src/native/appLifecyclePort.ts`
|
|
|
|
## Android release bundling cannot resolve the JSX transform
|
|
|
|
- **Symptom:** `app:assembleRelease` reaches Metro bundling and fails
|
|
`app:createBundleReleaseJsAndAssets` with `Cannot find module
|
|
'@babel/plugin-transform-react-jsx'`, while TypeScript, Jest, and debug
|
|
development may still pass.
|
|
- **Quick checks:** Resolve `@babel/plugin-transform-react-jsx` from the
|
|
`apps/mobile` package, then run a production Android Metro bundle. If
|
|
resolution fails from the app but the plugin exists only below the React
|
|
Native preset in pnpm's store, the app has been relying on transitive layout.
|
|
- **Root cause:** `react-native-css-interop` asks Babel to load the JSX
|
|
transform by package name but does not declare that package. pnpm's strict
|
|
dependency isolation therefore does not expose the preset's private copy to
|
|
the app-level Babel configuration.
|
|
- **Fix:** Keep `@babel/plugin-transform-react-jsx` as an explicit
|
|
`apps/mobile` development dependency aligned with the locked Babel cohort.
|
|
Do not solve this by changing pnpm hoisting.
|
|
- **Validation:** Resolve the plugin from `apps/mobile`, produce a
|
|
`--dev false` Android Metro bundle, and run the `Android Internal Build`
|
|
workflow through APK assembly.
|
|
- **References:** `apps/mobile/package.json`, `apps/mobile/babel.config.js`
|
|
|
|
## Android update stays on MainActivity without opening the installer
|
|
|
|
- **Symptom:** The Android App remains in the update progress overlay or never
|
|
opens `PackageInstaller` / `.InstallStart`. A successful feed request does
|
|
not prove that Android downloaded, verified, and handed off the APK.
|
|
- **Quick checks:** Filter the device logcat by the native update tag:
|
|
|
|
```sh
|
|
adb -s <serial> logcat -v threadtime TuttiMobileSecurity:I '*:S'
|
|
```
|
|
|
|
The UI phase narrows the boundary: **Preparing** means validation/storage,
|
|
**Downloading** or **Paused** is Android `DownloadManager`, **Verifying** is
|
|
exact-size plus SHA-256 verification, **Allow update installation** is the
|
|
per-app unknown-source permission handoff, and **Install the downloaded
|
|
update** is the Android package-installer handoff. Inspect the system-owned
|
|
job with:
|
|
|
|
```sh
|
|
adb -s <serial> shell dumpsys download
|
|
```
|
|
|
|
`DownloadManager` owns network reconnection and background continuation. The
|
|
App persists its download id and release metadata so reopening the App and
|
|
choosing the same release reconnects to the job or reuses a verified cached
|
|
APK. After verification it also records the target `versionCode`: a confirmed
|
|
install deletes the APK immediately, while a process replacement during
|
|
upgrade is handled on the next launch once the installed version reaches the
|
|
target. Installer cancellation or failure retains the APK for retry. The
|
|
in-App download cancel action removes the system job and cached artifact.
|
|
|
|
- **Relevant error codes:** `UPDATE_URL_INVALID`, `UPDATE_VERSION_INVALID`,
|
|
`UPDATE_SIZE_INVALID`, `UPDATE_SIZE_MISMATCH`,
|
|
`UPDATE_STORAGE_INSUFFICIENT`, `UPDATE_DOWNLOAD_FILE_FAILED`,
|
|
`UPDATE_DOWNLOAD_SERVER_FAILED`, `UPDATE_DOWNLOAD_MANAGER_FAILED`,
|
|
`UPDATE_DOWNLOAD_QUERY_FAILED`, `UPDATE_CHECKSUM_INVALID`,
|
|
`UPDATE_CHECKSUM_FAILED`, `UPDATE_CACHE_FAILED`, `UPDATE_CACHE_REPLACE_FAILED`,
|
|
`UPDATE_INSTALL_DEFERRED`, `UPDATE_INSTALL_STORAGE_INSUFFICIENT`,
|
|
`UPDATE_INSTALL_INCOMPATIBLE`, `UPDATE_INSTALL_CONFLICT`,
|
|
`UPDATE_INSTALL_BLOCKED`, `UPDATE_INSTALL_PACKAGE_INVALID`,
|
|
`UPDATE_INSTALL_FAILED`, `UPDATE_URI_FAILED`, and
|
|
`UPDATE_INSTALLER_LAUNCH_FAILED`. If Android's
|
|
per-app unknown-source permission is missing, `UPDATE_INSTALL_PERMISSION_REQUIRED`
|
|
means the user returned without granting it rather than an APK failure;
|
|
`UPDATE_PERMISSION_SETTINGS_FAILED` means the system settings page itself
|
|
could not be opened. The `EXTRA_RETURN_RESULT` flow reports the package
|
|
manager result through `android.intent.extra.INSTALL_RESULT`; project that
|
|
legacy result to the public `PackageInstaller` status before choosing the
|
|
in-App error. A cancelled activity without an install result is user
|
|
cancellation; an aborted or rejected result remains a failure.
|
|
- **Root cause:** The release feed, Android system download, artifact
|
|
verification, FileProvider, and PackageInstaller are separate trust
|
|
boundaries. A stale or malformed `sizeBytes`, a reused version URL with
|
|
different bytes, insufficient storage, a paused system job, or missing
|
|
unknown-source permission can stop the chain at different phases.
|
|
- **Fix:** Keep release URLs credential-free HTTPS and publish immutable APK
|
|
paths under `<tag>/<sha256>/`. Preflight both the APK and checksum before
|
|
uploading either object, then update `latest.json` last. Treat `latest.json`
|
|
`sizeBytes` and `sha256` as mandatory: both must match before PackageInstaller
|
|
opens. If the permission settings page opens, enable **Allow from this
|
|
source** and return to the App; the verified cached APK continues to the
|
|
installer automatically. Returning without granting permission produces a
|
|
localized recovery prompt and keeps the package for an explicit retry. Keep
|
|
the target version with that pending artifact, delete it on confirmed
|
|
success, and repeat cleanup on the first launch whose installed
|
|
`versionCode` reaches the target. For paused downloads, restore an allowed
|
|
network or cancel and retry rather than deleting app data.
|
|
- **Validation:** Run `pnpm --filter @tutti-os/mobile check`,
|
|
`./gradlew app:testDebugUnitTest`, and
|
|
`./gradlew app:compileDebugKotlin` from `apps/mobile/android`. On a physical
|
|
device, reproduce from Settings and verify determinate progress, cancel,
|
|
background/reopen continuation, offline pause/resume, exact-size/SHA failure,
|
|
and the installer handoff. Confirm that a late progress callback after cancel
|
|
cannot reopen the overlay. For permission recovery, revoke Tutti's
|
|
unknown-source permission, finish the download, grant the permission in
|
|
Android settings, return to the App, and confirm the installer opens without
|
|
another download action. Also deny the permission once and confirm the App
|
|
explains how to recover. Exercise an installer rejection and confirm it is
|
|
reported with the mapped failure rather than cancellation. Cancel once and
|
|
confirm the verified APK remains reusable; complete an update and confirm
|
|
the pending APK is gone either from the success callback or after the updated
|
|
App starts. For release recovery, simulate an APK-only upload, rerun the same
|
|
version with a different APK digest, and confirm the missing objects are
|
|
published before `latest.json` moves.
|
|
- **References:**
|
|
`apps/mobile/android/app/src/main/java/sh/tutti/mobile/MobileSecurityModule.kt`,
|
|
`apps/mobile/android/app/src/main/java/sh/tutti/mobile/MobileUpdateArtifact.kt`,
|
|
`apps/mobile/android/app/src/main/java/sh/tutti/mobile/MobileUpdateCoordinator.kt`,
|
|
`apps/mobile/android/app/src/main/java/sh/tutti/mobile/MobileUpdateInstallResult.kt`,
|
|
`apps/mobile/android/app/src/main/java/sh/tutti/mobile/MobileUpdatePendingInstallStore.kt`,
|
|
`apps/mobile/src/services/mobileUpdateService.ts`,
|
|
`tools/scripts/build-mobile-release-latest.mjs`
|
|
|
|
## Mobile quick prompts are missing from the plus menu
|
|
|
|
- **Symptom:** The connected Mobile composer does not show Quick prompts under
|
|
`+`, or the list is empty despite prompts saved on Desktop.
|
|
- **Quick checks:** Inspect the DeviceLink `agent_http` response for
|
|
`GET /v1/agent-quick-prompts`. A 403 response with `route_not_allowed`
|
|
identifies an allowlist gap.
|
|
- **Root cause:** Mobile loads prompt content only from the canonical device
|
|
list, so neither the UI nor workspace activity state invents a local
|
|
fallback.
|
|
- **Fix:** Keep the exact quick-prompt GET route in the DeviceLink allowlist
|
|
and keep all preference writes, quick-prompt mutations, and per-prompt
|
|
routes blocked. Refresh the authenticated-device quick-prompt service when
|
|
the `+` menu opens.
|
|
- **Validation:** Confirm the canonical prompt order is visible, search
|
|
matches title and content, and selecting a prompt adds text at the current
|
|
input position without replacing the existing draft or sending it.
|
|
- **References:** `services/tuttid/service/mobileremote/remote_protocol.go`,
|
|
`apps/mobile/src/services/mobileQuickPromptLibraryService.ts`,
|
|
`apps/mobile/src/components/MobileComposerDock.tsx`
|
|
|
|
## Mobile composer model and permission controls are missing
|
|
|
|
- **Symptom:** A remotely connected Android or iOS App can open an Agent
|
|
conversation and send prompts, but the model and permission controls above
|
|
the composer are absent.
|
|
- **Quick checks:** Query the host with
|
|
`tutti --json agent composer-options --agent-id <agent-target-id>` and confirm
|
|
that model and permission options are present. Then inspect the DeviceLink
|
|
`agent_http` response for the matching
|
|
`POST /v1/agent-providers/{provider}/composer-options` request. A 403 response
|
|
with `route_not_allowed` identifies a transport allowlist gap rather than an
|
|
AgentGUI capability or mobile layout problem.
|
|
- **Locale check:** Confirm the request body includes the phone's normalized
|
|
`locale` (`en` or `zh-CN`). If it is absent, tuttid falls back to the desktop
|
|
preference and the phone may show model, reasoning, or permission labels in
|
|
the desktop language.
|
|
- **Root cause:** Mobile loads authoritative composer options through the
|
|
DeviceLink HTTP bridge. If the exact read-side composer-options route is
|
|
absent from the bridge allowlist, the request is rejected before it reaches
|
|
tuttid's handler. The mobile UI fails closed and hides controls for options it
|
|
cannot verify.
|
|
- **Fix:** Add only the exact `POST
|
|
/v1/agent-providers/{provider}/composer-options` shape to the DeviceLink
|
|
`agent_http` allowlist, and include the mobile device locale in the request.
|
|
Keep unrelated provider routes and other HTTP methods blocked; do not
|
|
hardcode model or permission catalogs in the App.
|
|
- **Validation:** Run the focused `mobileremote` tests, confirm a composer-options
|
|
request round-trips through DeviceLink while GET and extra-path variants
|
|
remain forbidden, then use different phone and desktop languages to verify
|
|
that option labels follow the phone language and that model, reasoning, and
|
|
permission changes update the chips and persist in the selected Session.
|
|
- **References:** `services/tuttid/service/mobileremote/remote_protocol.go`,
|
|
`apps/mobile/src/services/workspaceActivityCommandAdapter.ts`,
|
|
`apps/mobile/src/components/MobileComposerSettingsSheet.tsx`
|
|
|
|
## Mobile composer option sheets do not respond
|
|
|
|
- **Symptom:** Model, reasoning, speed, and permission chips are visible and
|
|
accessible above the composer, but tapping them does not show their option
|
|
sheet; or rapidly alternating between a chip and the `+` button leaves two
|
|
overlapping menus, with the lower menu visible but unable to receive taps.
|
|
- **Quick checks:** Tap a chip on a real Android or iOS renderer and inspect the
|
|
JavaScript log. Repeated `BottomSheetModal::handlePortalRender` entries,
|
|
followed by a maximum-update-depth error, identify the legacy overlay path.
|
|
Without that error, inspect the native window hierarchy: two simultaneous
|
|
composer `Modal` windows identify competing local overlay state rather than
|
|
a slow or missing press handler. A missing composer-options response is a
|
|
different problem covered above.
|
|
- **Root causes:** `@gorhom/bottom-sheet` delegates `BottomSheetModal` rendering
|
|
through `@gorhom/portal`; its portal update path is incompatible with the
|
|
current React 19 Native renderer and recursively republishes the modal node
|
|
instead of presenting it. Separately, sibling composer controls must not own
|
|
independent window-level overlay state: queued taps can open both windows,
|
|
and the top window then intercepts input intended for the visible lower one.
|
|
- **Fix:** Keep the shared compact `NativeSheet` on React Native's window-level
|
|
`Modal`, with UI System scrim and panel tokens. Require a caller-localized
|
|
close label, expose backdrop dismissal as an accessibility button, handle the
|
|
iOS accessibility escape gesture, and represent an optional fixed height as
|
|
one value rather than silently accepting multiple snap points. Reserve
|
|
`@gorhom/bottom-sheet` for app-owned complex sheets that genuinely need its
|
|
gesture, keyboard, or multi-snap-point behavior. At composition boundaries
|
|
that offer multiple overlay entry points, keep one discriminated overlay
|
|
state in their nearest common owner and make child controls submit typed menu
|
|
intents.
|
|
- **Validation:** Open and dismiss the model, speed, and permission sheets more
|
|
than once, select the already active option without changing Session state,
|
|
and rapidly alternate between a settings chip and `+`; assert that no more
|
|
than one native modal is visible. Confirm there is no maximum-update-depth
|
|
error. Also verify touch backdrop, Android system-back, VoiceOver escape, and
|
|
screen-reader close button dismissal without hiding the sheet's interactive
|
|
descendants.
|
|
- **References:** `packages/ui/system/src/native/sheet.tsx`,
|
|
`apps/mobile/src/components/MobileComposerDock.tsx`,
|
|
`apps/mobile/src/components/MobileComposerSettingsSheet.tsx`
|
|
|
|
## Browser login completes but leaves the browser in front
|
|
|
|
- **Symptom:** Mobile completes provider login, but the browser stays in front.
|
|
Switching back to Tutti manually reveals that the account session is already
|
|
available or allows the login flow to finish.
|
|
- **Quick checks:** Confirm the localhost callback received a transfer code and
|
|
that transfer-code redemption succeeds. If the hosted result page's manual
|
|
“open App” action works, the account flow is healthy and only the browser-to-App
|
|
return path failed.
|
|
- **Root cause:** Opening a raw external browser makes the hosted result page
|
|
responsible for returning to `tutti://auth/login`. Mobile browsers can reject
|
|
a custom-scheme navigation started by delayed JavaScript because it no longer
|
|
carries a direct user gesture, even though the localhost bridge already
|
|
completed the account transfer.
|
|
- **Fix:** Keep the shared hosted login page, localhost bridge, and one-time
|
|
transfer-code redemption. Present that flow with `ASWebAuthenticationSession`
|
|
on iOS and AndroidX Auth Tab when the Android browser provider supports it, so
|
|
the operating system owns callback matching and foreground return. Android
|
|
falls back to the external system browser when Auth Tab is unavailable; the
|
|
hosted manual return action remains the recovery path for that fallback.
|
|
- **Validation:** On physical iOS and Android devices, complete a real provider
|
|
login and confirm the browser dismisses and Tutti becomes foreground without
|
|
a manual app switch. Also cancel once, exercise an Android browser without Auth
|
|
Tab support, and verify the transfer code is redeemed no more than once.
|
|
- **References:** `apps/mobile/ios/TuttiMobile/MobileWebAuthenticationSession.swift`,
|
|
`apps/mobile/ios/TuttiMobile/MobileBrowserAuthBridge.swift`,
|
|
`apps/mobile/android/app/src/main/java/sh/tutti/mobile/MainActivity.kt`,
|
|
`apps/mobile/android/app/src/main/java/sh/tutti/mobile/MobileBrowserAuthBridge.kt`
|
|
|
|
## Browser login returns to the App but remains signed out
|
|
|
|
- **Symptom:** Android opens the Tutti Web login page, completes the provider
|
|
login, and returns to the App, but the login screen shows a generic failure.
|
|
- **Quick checks:** Confirm the localhost auth bridge has stopped listening after
|
|
the callback. In read-only account-service access logs, compare the transfer
|
|
code redemption with the immediately following `user/v1/user_info` request. A
|
|
successful redemption followed by `SESSION_ID_MISSING` identifies this case.
|
|
- **Root cause:** Mobile API requests already authenticate from the encrypted
|
|
session using one explicit `Cookie` header. Leaving React Native's native
|
|
cookie jar enabled creates a second credential source; a stale native
|
|
`session_id` can be appended to or override the explicit session after browser
|
|
login.
|
|
- **Fix:** Keep the account session in encrypted native storage, set
|
|
`credentials: "omit"` on account and control-plane fetches, and attach exactly
|
|
one validated `session_id` header per API request. The system browser login
|
|
bridge returns only a short-lived transfer code; do not copy provider or
|
|
browser cookies into the App, and do not make API login depend on a WebView
|
|
cookie store. During migration, clear the legacy native `session_id` cookie
|
|
at startup and sign-out, but keep cookie installation outside the application
|
|
port contract.
|
|
- **Validation:** Complete a real browser login, verify the App reaches the
|
|
device page, restart the App, and verify the same account session still
|
|
authorizes device-list requests.
|
|
- **References:** `apps/mobile/src/services/accountClient.ts`,
|
|
`apps/mobile/android/app/src/main/java/sh/tutti/mobile/MobileSecurityModule.kt`
|
|
|
|
## Android DeviceLink opens a session and then repeatedly restarts
|
|
|
|
- **Symptom:** Pairing and the secure link succeed, and the App may briefly load
|
|
a workspace or session before returning to the launcher. Android process exit
|
|
history reports `EXIT_SELF` with status `2`; logcat contains
|
|
`fatal error: bulkBarrierPreWrite: unaligned arguments` instead of a Java or
|
|
React Native exception.
|
|
- **Quick checks:** Run `adb shell dumpsys activity exit-info
|
|
sh.tutti.mobile` and inspect a narrow logcat window for the Go fatal message.
|
|
This distinguishes a Go runtime abort from an Android lifecycle transition or
|
|
a React Native development reload.
|
|
- **Root cause:** A gomobile-exported Go method returned a pointer-bearing value,
|
|
first `([]byte, error)` and later `(string, error)` from Agent Live's
|
|
`Subscriber.Apply`. Generated cgo code moves pointer-bearing parameters and
|
|
return values through a packed structure whose address is not guaranteed to
|
|
satisfy the Go runtime write barrier's pointer alignment before Go 1.26. The
|
|
first successful response-body or Agent Live frame therefore exposes the
|
|
upstream cgo alignment defect. Returning final stream data together with
|
|
`io.EOF` also cannot be represented by the generated Java API, which chooses
|
|
either a byte array or an exception.
|
|
- **Fix:** Build every Android AAR and iOS XCFramework gomobile artifact with
|
|
Go 1.26.0 or newer, which includes the
|
|
[cmd/cgo alignment fix](https://github.com/golang/go/commit/d5b950399de01a0e28eeb48d2c8474db4aad0e8a).
|
|
Keep bulk stream data in a Java-owned byte array passed into Go and return only
|
|
a scalar byte count so final bytes plus `io.EOF` remain representable. The
|
|
Mobile and DeviceLink Makefiles pin the fixed toolchain; do not bypass that pin
|
|
when rebuilding native artifacts.
|
|
- **Validation:** Generate the Java binding and confirm the stream method is
|
|
`readInto(byte[])` with a scalar return, build the AAR and confirm every
|
|
`libgojni.so` reports the pinned Go toolchain, then install the AAR consumer,
|
|
connect on an ARM64 Android device, receive Agent Live frames, and observe
|
|
beyond the previous crash window with no new Go fatal message.
|
|
- **References:** `packages/device-link/mobile/link.go`,
|
|
`apps/mobile/android/app/src/main/java/sh/tutti/mobile/DeviceLinkModule.kt`
|
|
|
|
## Mobile direct DeviceLink consistently takes about ten seconds
|
|
|
|
- **Symptom:** A paired computer is online and often on the same LAN, but the
|
|
direct path regularly needs roughly ten seconds before it becomes usable.
|
|
Relay may appear much faster even though P2P eventually succeeds.
|
|
- **Quick checks:** Inspect sanitized `device_link.stage` events for
|
|
`direct_credentials_ready`, `direct_attempt_created`,
|
|
`direct_first_candidate_published`, `direct_attempt_ready`,
|
|
`direct_remote_candidate_received`, and `direct_connected`. If credentials
|
|
or attempt creation appear only after a five-second boundary, inspect the
|
|
native prepare bridge for a blocking full ICE description. If ready is early
|
|
but connected is late, isolate candidate exchange from authenticated
|
|
ICE/QUIC instead of changing Relay delay.
|
|
- **Root cause:** The caller previously waited for a complete host-only ICE
|
|
description, created the attempt to discover server STUN endpoints, replaced
|
|
the Participant, and then waited for a second complete description. The
|
|
Desktop owner also waited for its full description before publishing. Two
|
|
configured five-second gathering windows therefore sat on the critical path,
|
|
while TSH's lower-level implementation already published credentials and
|
|
trickled candidates during `Connect`.
|
|
- **Fix:** Use `candidateexchange.Start` and publish valid ICE credentials
|
|
immediately, allowing an empty candidate list. Run `PublishLocal` and
|
|
`FeedRemote` beside `Participant.Connect`; keep signed attempt reads/writes,
|
|
push hints, account authorization, and pairing state in the product adapter.
|
|
Mobile must drain the gomobile `ActionPump` next/resolve protocol rather than
|
|
use the legacy blocking `LocalDescription` bridge or own a second TypeScript
|
|
retry/poll scheduler. Resolve a publication successfully only after the
|
|
returned authoritative attempt contains its candidate snapshot. Android must
|
|
keep enough native operation workers for `Connect`, candidate actions, and
|
|
Relay work to progress concurrently.
|
|
- **Validation:** Cover an initial zero-candidate snapshot, a later local
|
|
candidate update, a ready peer with zero candidates, a later authoritative
|
|
remote snapshot, missed-push polling fallback, exact-snapshot publish retry,
|
|
and a successful stream probe. Compare stage deltas on LAN and external NAT;
|
|
do not log candidates, addresses, ICE credentials, fingerprints, tokens, or
|
|
payloads.
|
|
- **References:** `packages/device-link/candidateexchange`,
|
|
`packages/device-link/mobile/link.go`,
|
|
`apps/mobile/src/services/deviceLinkCandidateExchange.ts`,
|
|
`services/tuttid/service/mobileremote/candidate_exchange.go`
|
|
|
|
## Mobile shows output from a completed Session after foreground resume
|
|
|
|
- **Symptom:** After the App enters the background and is reopened, transcript
|
|
output from the previously selected Session continues streaming even though
|
|
that Session already ended on the host.
|
|
- **Quick checks:** Compare App lifecycle events with Native `TuttiDeviceLink`
|
|
Agent Live logs. Capture the subscription generation on the last delivery
|
|
before background and the first delivery after foreground. If a delivery from
|
|
the closed generation reaches the replacement JavaScript listener, the fault
|
|
is lifecycle fencing rather than canonical Session state or server replay.
|
|
- **Root cause:** DeviceLink intentionally stays open for a short background
|
|
grace period, but Agent Live used to share that lifetime. A suspended
|
|
JavaScript runtime could therefore miss or delay its stop call while Native
|
|
continued reading the old stream. React Native could queue those deliveries
|
|
and publish them to the newly attached listener after foreground resume. The
|
|
Native envelope carried workspace identity but no local subscription
|
|
generation, so the replacement listener could not distinguish queued old
|
|
output from its new stream.
|
|
- **Fix:** Stop Agent Live immediately at the Android and iOS background
|
|
boundary while preserving the underlying DeviceLink grace interval. Give
|
|
every bridge subscription a caller-owned generation, include it in every
|
|
Native delivery, and reject mismatched generations in both the bridge parser
|
|
and workspace live lane. Queue canonical workspace and selected-Session
|
|
reconciliation before opening the replacement stream.
|
|
- **Validation:** Background and foreground a connected workspace, invoke a
|
|
captured old listener after the replacement subscription exists, and verify
|
|
it cannot mark transport connected or change projected activity. Verify
|
|
matching-generation ready and event deliveries still apply, missing or stale
|
|
generations fail closed, and Android/iOS Native binding checks pass.
|
|
- **References:**
|
|
`apps/mobile/src/native/createMobileServicePorts.ts`,
|
|
`apps/mobile/src/services/workspaceAgentLiveLane.ts`,
|
|
`apps/mobile/android/app/src/main/java/sh/tutti/mobile/DeviceLinkModule.kt`,
|
|
`apps/mobile/ios/TuttiMobile/DeviceLinkModule.mm`
|
|
|
|
## Mobile stays connected after a long lock-screen interval but sends fail
|
|
|
|
- **Symptom:** After Mobile remains locked or backgrounded for at least the
|
|
DeviceLink grace interval, the previous conversation is still visible but the
|
|
next command fails. Returning to the computer list and connecting again
|
|
restores service.
|
|
- **Quick checks:** Compare `application.lifecycle_changed` and
|
|
`device_connection.phase_changed` diagnostics with Native `TuttiDeviceLink`
|
|
logs. If Native closed the link after its grace deadline while JavaScript
|
|
never published its own delayed disconnect, verify whether the foreground
|
|
transition used elapsed background time. Confirm the workspace runtime is
|
|
blocked until a replacement live lane reports ready.
|
|
- **Root cause:** Native lifecycle handlers continue running while React Native
|
|
timers may be suspended. When both layers independently scheduled the same
|
|
background deadline, Native closed the real link but JavaScript canceled its
|
|
still pending timer on foreground and resumed a stale connected workspace.
|
|
- **Fix:** Keep the close deadline Native-owned. Record background entry time in
|
|
the application service and decide short resume versus full reconnect from
|
|
elapsed time on foreground. Project one application-scoped connection phase,
|
|
retain the paused workspace until a hydrated replacement is ready, and let a
|
|
root overlay render that phase without owning recovery logic.
|
|
- **Validation:** Cover foreground before and after the grace boundary, an
|
|
active live-stream loss, failed reconnect plus explicit retry, and generation
|
|
fencing when the app backgrounds during recovery. On a physical device, lock
|
|
beyond the deadline, unlock, observe reconnect/synchronize presentation, and
|
|
send from the original conversation without revisiting the computer list.
|
|
- **References:**
|
|
`apps/mobile/src/services/mobileApplicationService.ts`,
|
|
`apps/mobile/android/app/src/main/java/sh/tutti/mobile/DeviceLinkModule.kt`
|
|
|
|
## iOS App crashes after loading the JavaScript bundle
|
|
|
|
- **Symptom:** A physical iOS device downloads or evaluates the React Native
|
|
bundle, then the App exits with `SIGSEGV`. The crash report points at
|
|
`objc_retain` or `objc_storeStrong` below
|
|
`ObjCTurboModule::performMethodInvocation` on the JavaScript thread.
|
|
- **Quick checks:** Capture the App console and the latest `.ips` crash report.
|
|
If the last log is JavaScript bundle evaluation and the faulting stack is a
|
|
synchronous Objective-C TurboModule invocation, inspect every blocking
|
|
synchronous Swift export used during startup before investigating Metro or
|
|
DeviceLink.
|
|
- **Root cause:** React Native's Objective-C interoperability path expects an
|
|
object from a blocking synchronous method. Exporting a Swift method with a
|
|
primitive `Bool` return can leave the invocation result buffer shaped like an
|
|
object pointer; retaining that value then causes an invalid-address crash.
|
|
- **Fix:** Return `NSNumber` from the Swift export. React Native converts it to
|
|
a JavaScript boolean, so the TypeScript contract remains `boolean`.
|
|
- **Validation:** Build and sign the physical-device target, install it, launch
|
|
it with the device console attached, and keep the process alive beyond the
|
|
previous post-bundle crash window. Confirm the initial lifecycle state still
|
|
arrives as a JavaScript boolean.
|
|
- **References:** `apps/mobile/ios/TuttiMobile/AppLifecycleModule.swift`,
|
|
`apps/mobile/ios/TuttiMobile/MobileNativeModules.m`,
|
|
`apps/mobile/src/native/appLifecyclePort.ts`
|
|
|
|
## iOS pod install intermittently reports pathname contains null byte
|
|
|
|
- **Symptom:** `pnpm --filter @tutti-os/mobile ios:pods` downloads and installs
|
|
every Pod, then fails during `Generating Pods project` with
|
|
`ArgumentError: pathname contains null byte` from
|
|
`Pod::Project#group_for_path_in_group`. Re-running may succeed.
|
|
- **Quick checks:** Confirm the stack ends in `Pathname#realdirpath` and the
|
|
workspace uses pnpm-linked React Native packages. CocoaPods version changes or
|
|
deleting `Pods/` may change the frequency but do not identify a durable fix.
|
|
- **Root cause:** CocoaPods preserves the filesystem layout for local Pods and
|
|
resolves their common base path through `realdirpath`. That resolution is
|
|
intermittently unsafe for pnpm-backed local Pod symlinks while the Pods
|
|
project is generated.
|
|
- **Fix:** Keep `apps/mobile/ios/cocoapods_pathname_workaround.rb` loaded from
|
|
the Podfile. It retains CocoaPods' group-building behavior but normalizes the
|
|
local Pod base with `cleanpath` instead of resolving the pnpm symlink through
|
|
`realdirpath`. Do not depend on retries or a CocoaPods downgrade.
|
|
- **Validation:** Run `ios:pods` from a clean checkout, confirm the Pods project
|
|
is generated, then archive the `TuttiMobile` workspace on the GitHub macOS 26
|
|
runner to exercise the same pnpm and CocoaPods path.
|
|
- **References:** `apps/mobile/ios/Podfile`,
|
|
`apps/mobile/ios/cocoapods_pathname_workaround.rb`,
|
|
[CocoaPods #12798](https://github.com/CocoaPods/CocoaPods/issues/12798)
|
|
|
|
## Mobile Jest discovers tests inside iOS Pods
|
|
|
|
- **Symptom:** `pnpm --filter @tutti-os/mobile test` starts hundreds of Hermes
|
|
or third-party suites under `apps/mobile/ios/Pods` after Pods are installed.
|
|
Project suites may pass, but the command fails on upstream fixtures, Flow
|
|
syntax, or snapshots.
|
|
- **Quick checks:** Inspect the failing test paths. Paths below `ios/Pods`
|
|
identify test discovery crossing into CocoaPods output rather than a product
|
|
test failure.
|
|
- **Root cause:** Jest recursively searches the package root. CocoaPods can
|
|
vendor JavaScript sources and tests, and generated native dependency
|
|
directories are not excluded by the default React Native preset.
|
|
- **Fix:** Keep `/ios/Pods/` in the Mobile Jest `testPathIgnorePatterns`,
|
|
alongside the Android generated tree and `node_modules`. Do not adjust Babel
|
|
transforms to make vendored test suites run.
|
|
- **Validation:** Install iOS Pods, run the Mobile test command, and confirm
|
|
Jest discovers only repository-owned suites.
|
|
- **References:** `apps/mobile/jest.config.js`
|
|
|
|
## React Native Pressable rows stack their children vertically
|
|
|
|
- **Symptom:** A Native button, list row, or app-owned tappable row declares
|
|
`flexDirection: "row"` but its icon, label, count, or trailing action appears
|
|
vertically stacked on a Fabric Android device. Plain `View` rows on the same
|
|
screen remain horizontal.
|
|
- **Quick checks:** Inspect the real device rather than relying only on
|
|
TypeScript or snapshots. Compare the bounds from `uiautomator dump` for the
|
|
direct children of the `Pressable`; consecutive vertical bounds identify the
|
|
failure even when the parent style contains the expected row declaration.
|
|
- **Root cause:** In the current React Native NativeWind/CSS interop renderer,
|
|
layout declarations on the `Pressable` host are not reliable for arranging
|
|
its direct children. Interaction, surface, size, and pressed-state styles
|
|
still apply, which makes this easy to mistake for stale Fast Refresh state.
|
|
- **Fix:** Keep interaction and surface styling on `Pressable`, but wrap its
|
|
visual children in a plain token-backed `View` that owns
|
|
`flexDirection: "row"` and alignment. Shared buttons and list rows should fix
|
|
this once in `@tutti-os/ui-system/native`; app-specific tappable rows should
|
|
follow the same inner-layout pattern.
|
|
- **Validation:** Perform a full JavaScript reload, then verify on Android that
|
|
button icon/label, list title/trailing action, and section label/count remain
|
|
horizontal. Confirm touch and accessibility actions still reach the outer
|
|
`Pressable`.
|
|
- **References:** `packages/ui/system/src/native/button.tsx`,
|
|
`packages/ui/system/src/native/list-row.tsx`
|