Skip to content

Skywire Mobile (Android) - #3797

Merged
mrpalide merged 42 commits into
skycoin:developfrom
mrpalide:skywire-mobile
Aug 9, 2026
Merged

Skywire Mobile (Android)#3797
mrpalide merged 42 commits into
skycoin:developfrom
mrpalide:skywire-mobile

Conversation

@mrpalide

@mrpalide mrpalide commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Did you run make format && make check? Yes

Fixes: #138

Changes:

  • Add Skywire Mobile, an Android build of the visor

How to test this PR:

Option 1: install the release APK (link).

Option 2: build it yourself from this branch:

make android-mobile
make android-apk APK_VERSION=0.0.1
cp android/app/build/outputs/apk/release/app-release.apk .

Then install the APK on a device or emulator:

adb install -r app-release.apk

mrpalide added 30 commits August 3, 2026 10:13
One binary for the phone — cmd/skywire-mobile: the visor, the CLI `config subtree, and the 4 client apps (skychat, skysocks-client, vpn-client, skydex-client) running in-proc via the launcher's existing registry, with the `app <name>` subtree as the per-app exec fallback.

New `mobile` build tag strips ~51 MB of embedded desktop assets, every one behind an existing graceful-absence path:
- pkg/geoip GeoLite2 db (30 MB) → OpenEmbedded errors, geo shows "??"
- manager UI static (6.8 MB) → "/" serves a one-line API-only page
- vendored skycoin-web browser wallet (11 MB) → /wallet/* 404s
- tpviz legacy UI (2.8 MB) → tp-viz routes 404/500
- browse handlers → 404 (desktop feature)

API-only on the phone: new hypervisor.dmsg_ingest opt-in (Fleet, Step 7) gates the remote-visor dmsg RPC ingest on mobile builds via a tag-paired seam; desktop behavior unchanged (ingest always on with the hypervisor).

Build/CI: make build-mobile / android-mobile / android-mobile-check / android-mobile-ndk (NDK-cgo release lane; -checklinkname=0 required by wlynxg/anet on GOOS=android), an `android` CI job with an 80 MB size budget, and .gitignore entries for the android project + keystores.

Measured: libskywire-mobile.so android/arm64 = 63,242,536 bytes (63.2 MB raw / 21.2 MB gz), from 110.6 MB unstripped multicall and a 265.6 MB naive 5-binary payload. Desktop smoke verified: authenticated localhost API, all 4 apps in-proc (skysocks-client Running against a public proxy, browsing via 127.0.0.1:1080), exec fallback child, Fleet toggle off/on, per-app log feeds populated in-proc, RPC port closed.

Test files reading stripped embeds are tagged !mobile (geoip, tpviz, wallet-handler, svc/geoip).
…re-mobile

Step 0's on-device smoke exposed that /api/about and --version read the repo-local pkg/buildinfo, which the mobile targets didn't stamp — and that stamping it with the bare short hash `git describe --always` yields on a tagless checkout FATALs the visor at startup (visorconfig.Parse semver-checks pkg/buildinfo.version). Add MOBILE_VERSION (git describe --tags, falling back to v0.0.0-<sha>) and stamp both buildinfo paths in build-mobile / android-mobile / android-mobile-ndk. Alias the misleadingly named WASM_BUILDINFO_PATH as SKYWIRE_BUILDINFO_PATH — it is the repo's own pkg/buildinfo, nothing wasm-specific.

Step 0 on-device smoke (DM-B70104, arm64, Android 15) passed with the NDK-cgo binary: on-device config gen (bionic DNS OK), dmsg connected, skychat + skydex-client + skysocks-client Running in-proc in one process (~88 MB RSS), all listeners loopback-only, mesh browsing verified from the host through the phone's SOCKS5. Sizes recorded: pure-Go 63,242,536 B, NDK-cgo 63,237,504 B (both 21.2 MB gz). vpn-client fails at the Linux route code as expected — Step 6 android glue is the fix.
New android/ Gradle project (Android Studio opens this dir): Kotlin / Jetpack Compose / Material 3, single Activity. Gradle 9.6.1 (committed wrapper), AGP 9.3.1 (built-in Kotlin — no kotlin.android plugin), Kotlin 2.4.10, Compose BOM 2026.06.01; minSdk 26, target 36, arm64-only.

- Brand theme #0072FF: white/black backgrounds, near-black dark surfaces, dynamic color off; container roles overridden so no M3 baseline purple.
- Splash (core-splashscreen): Skycoin mark on white/black, short fade.
- Bottom bar, 5 slots: Home · Chat · Skycoin mark (icon-only, opens the apps hub) · Wallet · Settings; hub grid with SkySOCKS/SkyVPN/SkyDEX/ SkyChat/Wallet/Fleet tiles + SkyMeet & Pay-with-Sky "coming soon"; SkySOCKS/SkyVPN/SkyDEX/Fleet push full-screen routes, back returns to the hub. Icons are Material placeholders behind Painter params; the Skycoin mark vector is a geometric placeholder awaiting the brand SVG.
- Placeholder BiometricGate so the nav structure is final from day one.
- jniLibs packaging: useLegacyPackaging=true (the core service execs libskywire-mobile.so from nativeLibraryDir — needs extracted libs).
- Docs: android/README.md (build/run/test); android/implementation-report.md (dated progress log).
- Makefile: android-apk / android-apk-debug bridge targets; mobile targets now also stamp the repo-local pkg/buildinfo (MOBILE_APPINFO) with a semver-safe fallback version — a bare-hash stamp fatals the visor at startup (visorconfig.Parse semver-checks it).

Verified on a real device (arm64, Android 15): splash → themed Home; all five bar destinations switch; hub tiles push and back returns to the hub; light + dark checked via screenshots.
- Real Skycoin cloud at res/drawable-nodpi/skywire_logo.png (trimmed to content, #0072FF = theme blue): untinted 44dp center bar slot, splash icon (53x40dp layer-list in the masked circle), adaptive launcher foreground (white-tinted on the brand-blue layer); the hand-drawn placeholder mark vector is deleted
- Skycoin typeface (Light/Regular/Bold + italics) in res/font, applied across the full Material 3 type scale (ui/theme/Type.kt); roles that default to Medium are pinned to Bold — the family has no 500 cut and they would silently resolve to Regular
- Shared designed top bar (ui/components/SkyTopBar.kt): centered bold brand-font title, tonal circular back button, background-blended container, actions slot; all pushed app routes use it via AppRouteScaffold

Verified on device (light + dark): splash, bar pill, hub, SkySOCKS top bar, launcher icon in the drawer.
- SkywireCoreService (specialUse FGS): execs the Go payload as a child process, captures its output to a rotating file, restarts on crash with backoff, stops it with a real SIGTERM via /proc + Os.kill (Android's Process.destroy() is SIGKILL and would skip the visor's graceful shutdown)
- First-run config: the payload's own `config gen` (keys never leave the device) + a Kotlin phone profile re-applied on every start — API pinned to 127.0.0.1:8000 with auth, RPC/pty/skywire-tcp/LAN-dmsg off, app-private paths, apps in-proc with autostart off
- Local-API client: admin bootstrap with a Keystore-encrypted random password, transparent re-login on 401, CSRF helper
- Home: Connect/Disconnect state machine + live visor card (PK, version, uptime, transports, dmsg latency, service health); Disconnect stays reachable while the core is starting or crash-looping
- Shared log viewer, three sources: core runtime ring (cursor protocol incl. restart reset), per-app feed, captured process output — the latter works even when the visor won't start
- Go: netutil via anet + SKYWIRE_ANDROID_API_LEVEL (Android 11+ blocks the stdlib netlink dump for app UIDs), interface fallback when `ip r` is empty, nil-safe Visor.Ports() for pty-less configs
The first app screen, and the shape the others reuse: list servers from
service discovery, point skysocks-client at one, start it, and watch what
it reports — plus a Logs action scoped to that app. No Go changes; every
endpoint already existed.
- ui/socks/: server list (~1050 entries with flag, country · region, key
  and version, searchable), status card with the visor's own detailed
  status and live transferred bytes, and the "SOCKS5 for other apps —
  127.0.0.1:1080" helper card with the port editable in a sheet.
- core/AppPreferences: plain DataStore for non-secret UI state, separate
  from the encrypted SecretStore. Remembers the last server, so the screen
  opens on a one-tap Reconnect.
- api/VisorApi: services(type) over /api/svc-fetch, app(), appConnections(),
  and a CSRF-signed updateApp() carrying only the fields given.
Two skysocks-client flags are now owned by the phone profile and re-pinned
on every launch:
- --addr host forced to 127.0.0.1. The generated ":1080" listens on every
  interface — on a phone that is a SOCKS5 proxy any device on the same
  Wi-Fi can use. The port stays editable from the screen.
- --reconnect always on. A cell handover is enough to lose a mesh route,
  and without it the app exits when its route group dies ("Liveness probe
  failed 2x; route group gone"), leaving a dead proxy until the user
  notices.
Configuring or starting stops the app first: the server-side restart on a
pk/args change races the outgoing proc, whose blocked accept() returns "use
of closed network connection" and sticks the app in Errored, and status:1
on an already-running app is a 500.
Two more found while building it. /api/svc-fetch needs its own HTTP client
— the visor dials service discovery over dmsg with a 15s budget per hop, so
the shared 15s call timeout aborted every opening list load. And the view
model's state writes go through MutableStateFlow.update: a read-modify-write
across a suspension point let the DataStore read clobber the core-state
collector, so the screen claimed the core was stopped while it was running.
Verified on a real device (Android 15, LTE, light + dark): list loaded,
connect established a route, the port sheet moved the listener 1080 → 1085
→ 1080 with the app staying connected and no stale listener, Disconnect
closed it and kept the server for Reconnect, and the saved server + port
survived an app reinstall. End to end from the phone itself (adb shell, a
different UID — the same reachability a third-party app gets): curl
https://api.ipify.org returns the real IP, the same call through
--socks5-hostname 127.0.0.1:1080 returns the exit node's.
…owed

  Two things the phone needs from the visor: an honest list of the dmsg
  servers it is actually on, and a say in which transport type gets tried
  first.

  Home's dmsg list was always empty, and not because of the card. /api/dmsg
  is the round-trip tracker: to report any visor it dials that visor's
  dmsgctrl port back over dmsg, and for the local visor that self-dial never
  lands here — "dmsg error 202 - cannot connect to delegated server" every
  ~5 minutes in the log — so it returns [] forever. The visor already knows
  its own sessions and ships them in the summary the card was fetching
  anyway, so Home reads summary.dmsg_servers: no extra request, one fewer
  round trip per poll. Rows carry the session carrier always, plus latency
  only when there is one — that is a separate hourly self-ping, and every row
  would otherwise read a bare, false "0 ms".

  Primary transport, dmsg by default on mobile. The knob existed
  (routing.transport_preference) but only sorted transports that already
  existed; both paths that CREATE one hardcoded STCPR → SUDPH → DMSG. They
  now walk the configured order via the new tptypes.PreferredOrder, so the
  first entry really means "try this first, fall back to the rest", and one
  order governs both creation and selection. The built-in default reproduces
  the old sequence exactly — a visor that configures nothing sees no change.
  The route-setup hook's 20 s STUN wait is now evaluated lazily: it gates
  SUDPH only, and an order that reaches dmsg or stcpr first must not pay it.

  - pkg/visor: transport_preference on RouterSettings, GET and PUT. Omitted
    leaves the order alone, [] reverts to the default, non-empty installs it;
    unknown names are an error rather than a silent drop, since a dropped
    typo would leave the caller believing a preference is in force that is
    not. Applied live and flushed to the config, so no restart either way.
  - core/TransportPreference: the phone's choice, hoisted to the front of the
    core's full default order rather than sent as a one-element list — that
    would flatten every untouched type to "unranked". Written into the config
    on every launch (the app is the source of truth; the visor persists its
    own copy on a live change, and this keeps the two from drifting).
  - ui/components/TransportPreferenceUi: card + sheet, dmsg badged
    "Recommended on mobile", stcpr and sudph with what they cost. Shared
    composables — SkyVPN shows the same visor-wide value.

  The PUT is a read-modify-write and has to be: it applies every field of the
  settings struct, so a bare body would also send min_hops: 0, which the
  router reads as routing disabled. Changing the primary while connected
  re-dials the client — the setting steers route setup, so an established
  route keeps whatever transport it already rides. That re-dial is why the
  connect body is now a plain suspend startWith(): re-entering the one-action
  guard from inside it would cancel the job making the call.

  Verified on device. Four servers listed right after Connect. With dmsg
  primary, Reconnect goes straight there — "Dialing transport … via dmsg" 4 s
  after the tap, transport saved, "Found direct transport to destination
  (type=dmsg)", route group up ~9 s in, no STCPR or SUDPH attempt and no STUN
  wait. Switching DMSG → STCPR → DMSG in the sheet logged
  SetTransportPreference each time and landed in the config immediately with
  min_hops: 1 intact. go test green for pkg/visor/..., pkg/router/... and
  pkg/transport/types/... (new coverage for the ordering helper and the
  readback); mobile-tag build green.
…phone

The Chat tab is skychat's own web UI in a WebView, not a native rewrite:
one chat codebase, and the phone layout is a breakpoint inside it rather
than a second UI. Opening the tab is what starts the app — autostart is
off for every app on the phone — so the screen waits for the visor API,
starts skychat, polls its own listener until it answers, and only then
creates the WebView. Pointing it at a port nothing is listening on
replaces the chat with Chromium's error page and needs a manual reload.

skychat now runs password-gated on the phone, and that is the part worth
reading. Android has no per-app network namespace: a listener on
127.0.0.1 is reachable by every app holding INTERNET, and skychat's
surface is the whole account — history, contacts, sending — so left open
it would be readable and writable by anything installed, with no prompt.
The phone profile turns on the gate skychat already ships. A second
device-local secret is hashed the way commands/auth.go verifies
(<hex salt>:<hex sha256(password‖salt)>, 16-byte salt) into
<local_path>/skychat-password, written by ConfigManager *before* the app
is ever started so there is no window in which the surface is open, and
the WebView answers the challenge in onReceivedHttpAuthRequest —
Chromium then reuses the credential for every subresource, XHR and the
SSE stream. The record is re-checked rather than blindly rewritten: the
salt is read back and the stored secret re-hashed against it, so a
rotated keystore rewrites the file instead of the app 401-ing against
its own gate, and a running skychat holding a stale file is stopped and
started once (that reloads --password-file) before the failure is
reported. --portless is stripped from the argv: the visor's
/skychat/proxy mount serves the same handler under a path prefix, but
every fetch in the page is root-absolute (/history, /sse, /message), so
the UI cannot run there and the port is the only way in.

A WebView left at the default wrap_content breaks every percentage
height in the page. Compose measures it with an AT_MOST spec and
Chromium then treats the layout viewport height as indefinite: measured
live, innerHeight was 777 while getComputedStyle(html).height and body
were both 0px. Every flex column collapsed — the conversation list
rendered nothing below the tabs and the composer rode up under the
header instead of sitting at the bottom. MATCH_PARENT layout params is
the whole fix, and it is load-bearing, not cosmetic.

WebView also needs MODIFY_AUDIO_SETTINGS, not just RECORD_AUDIO. With
RECORD_AUDIO granted, getUserMedia still failed with a bare
"NotReadableError: Could not start audio source"; logcat named it —
"cr_media: Requires MODIFY_AUDIO_SETTINGS and RECORD_AUDIO. No audio
device will be available for recording". Install-time permission, no
prompt, and without it neither voice nor video messages can record.

- ui/chat/: ChatViewModel brings the surface up and hands the screen a
  URL; ChatWebView holds the Android half. Android back maps to the
  page's history, which already pushes an entry when a conversation
  opens and answers popstate by returning to the list — so back closes
  the chat and back again leaves the tab, with no second mechanism.
  Top-bar overflow carries Reload and Logs, the shared viewer scoped to
  skychat.
- Uploads go through onShowFileChooser → StartActivityForResult, and the
  callback is answered on cancel too: an unanswered file chooser leaves
  the page's input dead for good. Downloads are not left to the page —
  WebView ignores the download attribute and would render an image or a
  video *in place of the chat*, so same-origin main-frame navigations
  become DownloadManager jobs carrying the gate's Authorization header
  (it fetches in its own process with no session of ours), and anything
  off-origin goes to the browser.
- core/SkychatProfile owns the argv pins and the password record;
  api/SkychatApi is the client for skychat's own surface (the credential
  and a readiness probe), separate from VisorApi on :8000.
- The app scaffold consumes its window insets so the Chat WebView's
  imePadding does not count the navigation bar twice — once in the
  scaffold padding and once in the keyboard inset it is measured from.

cmd/apps/skychat, the phone pass. The one-pane breakpoint already
existed; what did not survive contact with a 406px viewport: the
composer needed 489px and overflowed by 62px, which scrolled the
header's back button off screen — .message-input had flex:1 with no
min-width:0, so it refused to shrink below its placeholder's intrinsic
width. The toast reserved 100vw - 360px for a sidebar that is not beside
anything in one pane, leaving every toast a ~46px column of wrapped
words. And the image lightbox had no zoom of its own while the app —
rightly — keeps page zoom off, so a received photo could not be looked
at: it now pinches, double-taps and drags to pan. The message field is a
textarea rather than a single-line input, one row growing to three and
then scrolling with its scrollbar hidden; Enter sends where there is a
real keyboard and inserts a newline on a touch device, where it is the
only way to write a second line. Two sizing traps in that: scrollHeight
is content+padding and the field is border-box, so the border has to be
added back or every row is 2px short and grows a scrollbar it does not
need; and sizing the composer while it is hidden — it is, until a
conversation opens — measures 0 and pins it shut until the first
keystroke.

Verified on device against a desktop visor, with DevTools attached over
adb forward for the measurements. Text both ways; a 96 KB PNG sent (✓✓)
and a 196 KB PNG plus a 3.2 MB MP3 received, rendering inline with a
working player. list → chat → Android back → list with the unread badge
intact. getUserMedia({audio}) OK and {video,audio} → video=1 audio=1,
both NotReadableError before the permission fix. Composer 42px at rest
with msgForm overflow 0 at a 406px viewport (was 62px); toast 383px on
one 40px row. Lightbox double-tap on a received screenshot → scale 2.5
anchored on the tap, a synthetic two-finger pinch 1 → 3. Attach opens
documentsui and cancelling returns with the conversation still open;
overflow → Logs live-tails skychat. go test ./cmd/apps/skychat/... green.

Left open: one send arrived three times on the desktop and one reply
twice on the phone, not reproduced since — the log around it is route-
group churn between the two visors ("Failed to send periodic SACK …
transport is not set up", a new route group per message), the shape of a
retransmit after an unacked send. Nothing in the embed sends twice: the
page holds one EventSource and ignores the server's history replay.
Worth chasing with a reproduction. Leaving the tab destroys the WebView,
so returning reloads the page and loses which conversation was open —
the WebView has to be Activity-scoped, since an application-context one
crashes on the composer's <select> popup, so a persistent host is its
own change.
…on the phone

  skychat:// links (skychat://<pk>[/<group-id>] and skychat:invite:<…>) now open
  the Chat tab's Add-by-address dialog with the address filled in and resolved,
  and stop there: adding a contact, opening the chat or joining a group stays the
  user's tap. Only the skychat scheme is claimed — skycoin:// belongs to the
  Skycoin wallet app, which is installed on the same phones.

  Voice calls were unreachable on the phone for two unrelated reasons.

  Everything skychat relays to the visor — pairing, group chat, and all of
  /voice/* — goes through its pair-RPC client, which dials cli_addr. The phone
  profile leaves cli_addr empty on purpose: on Android any installed app holding
  INTERNET can connect to another app's loopback listener, and the visor RPC has
  no authentication of its own. The dial therefore always failed, /voice/incoming
  answered 503, and the chat UI hid the Call row exactly as it is meant to. A
  visor now publishes itself for the apps running inside its process, and skychat
  prefers that over dialing — the mirror image of an internal app publishing its
  HTTP handler for the visor to serve in-process. What the registry hands out
  wraps Close as a no-op: the caller treats it as an RPC client and closes it on
  every redial.

  Second, a call to a phone timed out having never tried dmsg. The voice dialer
  prefers skynet but handed that attempt the caller's whole 30 s, and route setup
  to a peer with no inbound reachability does not fail fast — setup nodes, local
  BFS, then a direct-transport dial. The skynet leg now gets its own slice, which
  is what preferring a carrier has to mean if the fallback is to matter.

  Also: the chat action is named "Call" (banner "Incoming call"), and the hub drops
  the Pay-with-Sky tile so at most one "coming soon" tile is shown at a time.

  Verified on the Android emulator with a desktop visor as the second party: link
  → resolved dialog with nothing added; Call present in the conversation menu;
  desktop → phone call rang, answered, and ran with a live timer. Audio is still
  silent both ways on Android — pkg/skychat/call compiles the PulseAudio backend
  there and has no device to open; a real backend is a follow-up.
  A call on the phone was silent in both directions. The visor has no audio
  device on Android — GOOS=android satisfies the `linux` build tag, so it
  compiles the PulseAudio backend and finds nothing to talk to — so it now
  borrows the one device that exists: the host app's. pkg/skychat/call.Bridge
  presents that app as an ordinary Source/Sink pair (per-session fan-out for
  capture, a clipping mix for playback, since one device serves every call), and
  the app plays the device with AudioRecord/AudioTrack over two long-lived PCM
  streams on the visor's local API. Capture asks for the platform's echo
  canceller, which nothing in the Go pipeline provides.

  An incoming call is now the call SCREEN rather than something to read in a
  shade: full screen on any tab but Chat (where the embedded page draws its own),
  and raised from the background by a full-screen intent, which is the only way
  Android lets an app put a call in front of someone. A missed call becomes a
  message in its conversation, and the Calls tab is those records read back out
  of message history — no second store, and it survives a restart exactly as well
  as the messages around it.

  Five defects had to be fixed to get there, four of them defaults that were
  simply wrong for a phone:

  - The /api route group applies middleware.Timeout(30s) — a deadline on the
    whole request. The microphone stream was severed and silently reopened every
    30 seconds for the length of a call, with no error logged anywhere. Both
    audio routes now sit outside that group, as the notification stream already
    had to.
  - A foreground service typed `microphone` is REFUSED with a SecurityException,
    crashing the app, unless RECORD_AUDIO is already granted — and a call can
    arrive before the user has ever been asked. The service starts as
    mediaPlayback (audio out, none in, which is what it honestly is) and promotes
    itself the moment the grant lands, before recording a frame.
  - skychat only ran while its tab was open. A chat app that runs only while you
    are looking at it cannot receive anything: no message notification, no
    ringing call, no missed call recorded. Its autostart is now pinned on.
  - --persist is off by default, so nothing was ever stored: every conversation
    was erased on the next core restart, which happens on every crash, reconnect
    and app update.
  - The voice dialer gave its skynet attempt the caller's whole budget, so a call
    to a phone timed out before dmsg — the carrier that works — was tried.

  Home keeps its layout; the visor card now shows public key, version and uptime,
  with transports (by type, not a bare count), DMSG servers and service health
  behind More info.

  Verified on the Android emulator against an audio-capable desktop visor: a call
  answered on the phone records and plays, the permission is asked for exactly
  when a call connects and the call continues receive-only until it is granted,
  a missed call produces a notification, a row in the conversation and an entry
  in the Calls tab with its time and a call-back tap.

  Declined and no-answer are not yet distinguishable — both are a call that rang
  and stopped, and telling them apart needs a decline signal shared by the chat
  page and the phone. Both log as missed.
  Calling someone from the phone showed nothing. The full-screen call UI was not
  failing to appear — it had nothing to appear for: a call being PLACED is in
  neither the ringing list (that one is the callee's) nor the active list (that
  one starts at "answered"), so for the whole ring the caller's own visor could
  not say a call existed at all. pkg/skychat/call.Manager now tracks outbound
  invites while they are in flight and the visor serves them at
  …/skychat/voice/dialing, which is the caller's missing half of the picture.

  Registering the dial also makes hanging up DURING the ring possible. There is
  no session to close yet, so cancelling the invite is the hang-up; until now a
  caller who changed their mind could only wait out the dial timeout.

  The call screen also no longer steps aside on the Chat tab. It was excluded
  there because the embedded chat page draws its own banner and panel — which is
  precisely why a call placed from that page's menu showed nothing. A call is not
  something to notice inside a conversation list; it is what the phone is doing.
  Placing, being rung, and being in a call are now one screen in all three
  moments, on every tab, with only the controls changing.

  Verified on the emulator against a desktop peer: a call placed from the chat
  page shows Calling… with Hang up, becomes the connected screen with its timer
  and controls when the far side answers, and lands in the Calls tab as
  "Outgoing call · 1m 4s" beside the earlier missed one.
… hub

  The hub was decoupled from skychat so that anything — an app, the visor, later
  the hypervisor — can publish and let the hub decide which tier can reach the
  user. The phone subscribed to it correctly and then undid that at the last
  step: every event was presented as a SkyChat message, on one "messages"
  channel, with CATEGORY_MESSAGE, a SkyChat title fallback, and a single
  notification id shared by all tagged events.

  That was already wrong rather than merely unfuture-proof: skydex-client
  publishes market and lifecycle alerts today, so its alerts landed under
  "Messages", and its skydex-lifecycle tag could replace a chat notification
  outright.

  Presentation is now driven by the event's app field, which the visor stamps and
  an app cannot forge. Channels are per-app and created on first use, so an app
  the phone has never heard of is neither dropped nor special-cased: it gets a
  channel named after itself at default importance, and the user gets a real
  switch for it in system settings the day it first appears. The CHANNELS table
  only gives the apps we know a better label and a deliberate importance — adding
  a row tunes an app, adding a notification needs no row at all. Tags are
  namespaced by app so two publishers that both say "lifecycle" cannot overwrite
  each other, and untagged events stack.

  (*Visor).Notify stamps the visor's own app name for events the visor raises
  itself rather than relays, so a notification from visor-side code is one line
  at the place that already knows the thing happened. It is unused today by
  design: it is the seam the next notification is written against.

  Verified on the emulator: a missed call published by skychat arrives as
  channel=app_skychat category=msg importance=4, routed from the event's app
  field into a channel created at run time, where it previously landed on the
  hardcoded messages channel.

  Not addressed here, and worth knowing: the hub's stream is live-only. With no
  subscriber a publish falls through to the host-OS tier, which is nothing on
  Android, so anything raised while the phone's connection is down is lost. The
  bridge reconnects within ~2s and that is the whole mitigation; a bounded
  backlog plus a `since` cursor on the stream would close it properly.
…ry surface

  A nickname lived in the chat page's localStorage, which meant the two places a
  name matters most could not see it: a notification title, which skychat composes
  in Go before any UI is involved, and the phone's native call screen, which is
  Kotlin and cannot read a WebView's storage. Both showed 66 hex characters for
  someone the user had already named — an incoming call from "Alice" announced
  itself as 037f16ce…ecf2. The profile package had already noted the shape of the
  problem: the address book fixed one device at a time.

  The book is now the visor's. pkg/skychat/contacts keeps it as one small JSON
  file beside the profile, written temp-then-rename so a crash mid-write cannot
  turn a power cut into "all my contacts are gone and the app refuses to start the
  feature". skychat serves it at /contacts, with /contacts/import for migrating a
  browser's surviving copy — import fills gaps only and never overwrites, which is
  what makes it safe to run on every page load: it cannot revert a rename made
  since, on this device or another.

  displayName(pk) is now the single answer to "what do we call this key", and
  every notification title goes through it — direct messages, group messages,
  files, missed calls. The page keeps its in-memory map for the synchronous render
  paths and writes through to the server. The Android app caches the book and
  resolves it for the ringing notification and the full-screen call screen.

  Resolution order is the same everywhere: the operator's nickname, then the name
  the peer publishes about itself, then the shortened key. The published name is
  never fetched on the notification path — that is a network call — the UI writes
  one into the book when there is no nickname yet, so by the time a notification
  needs it, it is already the first case. A name the user chose is never
  replaceable by the person it labels.

  Side effects worth having: a nickname now survives a WebView cache clear, and
  the same visor's phone app and desktop UI agree about who someone is.

  Verified on the emulator by naming the desktop peer: the message notification
  title, the ringing notification and the call screen all read "Alice" where they
  previously read hex, and the book is on disk at the app's work dir.

  The Contact Settings dialog said the name was saved "on this device", which
  stopped being true; corrected rather than left to mislead. Contact membership
  and avatars stay per-browser — only the name is needed outside the page, and
  avatars are data-URL blobs that want a different store.
…ding UI embedded

  The market public key is a native field with validation and a
  recent-markets dropdown; everything past the handshake is the page the
  desktop already serves, in a WebView below it.

  Connecting is three steps in one action: PUT …/apps/skydex-client with
  the argv carrying --market-pk, status 1, then POST /api/connect on the
  app's own control API once its listener answers. That last call is what
  makes it one step — the flag alone only pre-fills the page's connect
  form, because the engine never dials on its own. The visor's `pk` field
  is allow-listed to skysocks-client and vpn-client, so the key travels in
  `args`. /api/status is polled: the page can change the connection too,
  and a header claiming "connected" over a page that isn't would be worse
  than no header.

  The trading UI ships with no authentication of any kind, and Android has
  no per-app network namespace — a loopback listener is reachable by every
  app holding INTERNET, with the live market session, the registered
  wallet addresses and order placement behind it. skydex-client now takes
  --password-file in skychat's format: with a password set the wrapper
  serves basic auth on --addr and moves the vendored engine to a loopback
  port drawn fresh at every start, failing closed if the credential is
  unusable. Off by default, so desktop is unchanged. It closes the
  documented port, not the engine's own — that needs skydexclient.Run to
  accept a net.Listener upstream.

  The page's own header duplicated the native row exactly, and its ~10 kB
  of CSS carries no media query at all, so its five tabs hid Settings off
  the end of a scroll. Both are handled by one stylesheet injected at
  page-finished — the page is React, and a removed node comes back on the
  next render.

  Verified on the emulator against a live market: connect, disconnect,
  reconnect from recents, argv and recents surviving a reinstall, 401 from
  another UID on 8051, and skychat still answering its own gate after the
  shared profile refactor.
…ly works

  My Listings is ten columns. A phone showed five and cut off the rest,
  and the column past the edge was Actions — the one holding Cancel. A
  sideways scroller does not fix that: nobody scrolls a table they cannot
  see the end of, to reach a button they do not know is there.

  Each row is now a card. What a closed card shows is chosen by column
  name rather than position — Type, Amount, Price, Status/Lifecycle, four
  at most — plus the Actions cell, always, as a full-width button.
  Everything else (the id, the escrow address, the transaction hashes, the
  timestamps) sits behind a Details toggle. Amount and Price take the
  weight the market's own product card gives them, so a row reads as a
  trade rather than as a grid.

  A closed card shows one status badge, not the whole lifecycle chain: of
  "Pending deposit → Confirmed → Listed as product" only the last is news,
  and the rest come back on open, where "why is my deposit still pending"
  is actually the question. An Actions cell with no button is dropped
  rather than left as a labelless dash on a line of its own.

  This is the part that cannot be pure CSS — a <td> carries no clue which
  column it is in — so the labels are copied off <thead> and the choices
  made per cell. The page re-renders its tables every eight seconds while
  polling, so a MutationObserver re-applies all of it and the open cards
  are remembered outside the DOM; otherwise every card snaps shut
  mid-read.

  And a real bug behind all of it: a WebView with no onJsConfirm silently
  suppresses window.confirm() and hands the page false. The page guards
  cancelling a listing or an order behind exactly that call, so Cancel did
  nothing at all — the worst way to fail on a screen holding escrowed
  coins. chromeClient now answers onJsConfirm/onJsAlert with a native
  dialog.

  Verified on the emulator against a live market with a listing, two
  cancelled orders and a history entry: all three tables render as cards,
  Details expands to all ten fields and survives the re-render, and Cancel
  raises its confirmation.
The whole phone now exits through a Skywire visor. This is the one part
of the app that needed new Go, because the thing vpn-client wants —
/dev/net/tun — is the one thing an Android app may never open.
Android hands out a TUN only through VpnService, and only after the user
has granted the system's consent. The core runs as a child process, so it
cannot inherit the descriptor either; it has to be sent. SkyVpnService
creates the interface and passes its file descriptor to the visor over an
abstract unix socket as SCM_RIGHTS ancillary data. On the Go side
newTUNDevice() returns a device with no descriptor at all — it cannot
have one, since the shared client calls it before the exit has assigned
an address, and on Android the address is an argument to creating the
interface rather than something set afterwards. So SetupTUN is where the
interface actually appears, and it is the whole configuration in one
call. The descriptor goes non-blocking before os.NewFile: only then does
the runtime poll it, and only then does closing it release a Read that
would otherwise sit in the kernel forever when the interface is replaced.
The Linux client shelled out to ip and nmcli and raised capabilities.
None of that exists for an app process, and none of it is needed —
VpnService.Builder declares the address, MTU, DNS and routes and the
system installs them. So the client half of os_linux.go moved into
os_client_linux.go (now linux && !android) and the Android file says the
same intentions the way Android allows: AddRoute and ChangeRoute are
already satisfied, deleting the default route means dropping the
interface, and a /32 direct route is nothing to undo because the visor's
own UID never entered the tunnel. tun_device_unix.go was tagged !windows,
which quietly made it the Android path too; it is now !windows && !android.
That exclusion is not a detail. The visor is a child of this app and
shares its UID, so without addDisallowedApplication the dmsg traffic
carrying the tunnel would be routed into the tunnel.
The killswitch is real rather than advisory because the service keeps its
own copy of the descriptor: the core closing its copy does not take the
interface down, so a dropped tunnel leaves a live interface nobody
drains, and the packets go nowhere. It is released on an explicit down
from the core, on a control-socket EOF when the killswitch is off (the
core was killed, not stopped), or when the user disconnects. A visor
crash-restart keeps blocking; a terminal core stop does not, because a
phone blocked with nothing left to reconnect is a bug, not a killswitch.
One unit bug fixed on the way: AppConnection.latency was decoded as
nanoseconds and divided by a million, but the visor already converts
before serializing, so the wire value is milliseconds. Harmless on
SkySOCKS, whose latency is always zero and whose row therefore never
renders; it would have shown every VPN ping as 0 ms.
Verified on the emulator against a Singapore exit: traffic from a routed
UID leaves at the exit's address while ip rule shows every UID except
this app's routed into tun0 — a one-UID hole where addDisallowedApplication
put it. With the killswitch on, kill -9 on the visor leaves tun0 up and
HTTP times out; with it off, the same kill releases the interface and
traffic is direct within seconds. On a real device over LTE: consent,
connect, interface up and stats ticking. Killswitch under Doze and
long-idle survival on aggressive OEM ROMs still want a real device.
…vices

Install the phone app and every conversation you have had is simply not
there. That is not a bug to fix: a new visor is a new identity with an
empty store, and none of those messages were ever addressed to this key.
Nothing syncs, and nothing can without an identity that spans devices.
So this is the honest alternative — a file you carry across yourself.
Export writes one JSON document: the address book and every stored
message, one-to-one and group. Import merges one back. The line drawn is
data against identity. Messages and the names you gave to keys travel;
the visor's keypair, group membership and its key material, pairing
ratchets and transfers in flight do not. Importing a group's messages
puts the conversation on the new device to read — it does not make that
device a member, which still means rejoining, and the screen says so
rather than leaving it to be discovered.
Import is deliberately not Append in a loop. The per-peer rate limit
does not apply: it exists to stop a peer filling your disk over the
network at twenty messages a minute, an operator restoring their own
archive is not that, and applying it would have handed back a few
messages per conversation while looking like it had worked. Records
already held are skipped, so importing the same file twice changes
nothing — identity is the envelope ID where there is one and timestamp
plus direction plus text where there is not, because older plain-text
messages carry no ID. Every other guardrail stands: the size cap, the
whitelist, a full store, and the per-peer cap that still evicts
oldest-first.
What it reports matters as much as what it stores. The reply counts the
records that fall outside the retention window, which the sweep will
take within the hour, and the ones the per-peer cap pushed back out.
Both are silent losses otherwise, and "imported 2000" while five hundred
survive is exactly the number that sends someone to wipe their old
device too early. The summary names the flag to change in each case.
Two details belong to the phone rather than to skychat. Export is a
plain navigation instead of a fetch and a blob, because the app already
turns a same-origin navigation into a real download with the basic-auth
header attached and a blob URL built inside the WebView would not get
one. The filename rides in the path for the same reason: that route
never sees Content-Disposition, so it names the file from the URL, and a
bare /export would be saved as export.bin — which the import picker then
filters out for not being JSON. Neither side needed new Android code.
The bell went with this. Notification preferences were the only settings
this UI had and they had their own button; they are now the first
section of the one Settings dialog, and the permission ask still rides
on opening it — browsers only honour that request from a user gesture,
and the preference defaults to on, so without it a fresh profile drops
every notification in silence.
Verified against a running skychat with persistence on: an archive
imports, exports back with the same contents, and re-importing the
exported file stores nothing and reports every record as already held.
Imported contacts appear in the sidebar as conversations. A wrong file
answers in the server's own words rather than a status code. Rendered at
phone and desktop widths. Tests cover both history backends — not rate
limited, idempotent, dedup without IDs, cap and retention counted, the
limits that still reject — and both handlers, including the round trip
onto a second empty store.
Off by default, and that is the feature. The phone ships API-only: the visor's
local HTTP API on loopback and nothing else. The config section it lives under
is called `hypervisor` for historical reasons, but on this build it is just the
API. Enabling Fleet hands that hypervisor its dmsg client, which starts the RPC
listener other visors dial in on; from then on any visor carrying this phone's
key in its own `hypervisors` list connects and reports status here. What
arrives is status — and one action.
That action is the only new Go. The mux had `shutdown` and `RestartApp` but no
way to restart a whole visor, and the visor's own `Reload` RPC is exactly that
— close the module stack, re-read the config, run again, same process — already
in the API interface and the RPC client. So `POST /api/visors/{pk}/restart`
wraps it. It answers 202 rather than 200 because Reload cannot answer: it tears
down the RPC transport the call is riding, so the caller's outcome is an EOF
whether the restart worked or the visor died. The route reports dispatched, not
completed, and the real outcome is the visor leaving /api/visors-summary and
coming back. `cli visor reload` has always worked this way.
`hypervisor.dmsg_ingest` is read once, while the visor builds its module graph,
so the toggle restarts the core. The preference is the source of truth and the
phone profile writes it into the config on every launch, the same arrangement
as the transport order. SkywireCoreService.restart is deliberately not a
suspend function on the caller's scope: Fleet is a pushed route, and a back
press would cancel it somewhere between the stop and the start and leave the
phone with no core at all. It runs on a process-scoped job behind a mutex, and
callers follow it through CoreServiceState. Home's auth recovery now goes
through the same helper — its own version waited only for Stopped, which Failed
never becomes.
Names live on the phone, not on the visor. Fleet is a read-only window onto
those machines and a label would be a strange first exception; the cost is that
names do not travel to another device, and the dialog says so. Transports are
broken down per carrier rather than totalled, because "stcpr 10 / webrtc 14" is
the diagnosis on a visor that is reachable but unroutable and "24" is not. How
to add a visor sits behind the app bar's ?, since it is read once.
The bug worth recording: the first cut polled visors-summary every five
seconds. Each poll makes the visor fire a Summary RPC to every remote over
dmsg, and from a phone those routinely exceed the server's own five-second
budget for them; polling faster than they complete stacks calls onto one dmsg
stream until it breaks. The peer then cycled summary-slow → connection shut
down → evicted from remoteVisors → redialed, roughly once a minute, forever.
The screen looked fine throughout, because the server keeps serving an evicted
visor from its summary cache and renders it online for three minutes —
deliberate anti-flicker — while every action route resolves against
remoteVisors and answered 503. Two green screens and a button that did nothing.
Polling every fifteen seconds ends the cycle; a 503 restart now retries three
times at three seconds, which is what the server's own "retrying" invites; and
last_seen_at is rendered whenever the snapshot is over forty-five seconds old,
including under a green dot, because that age is the only visible sign of the
window where a row looks live and actions fail.
Two smaller ones. The log viewer claimed "No log entries yet." while it was
still fetching — harmless locally, wrong for a remote visor whose first page
travels the whole ring buffer over dmsg at eight seconds. And the straight
apostrophe has broken sidebearings in the Skycoin typeface, rendering "this
phone's key" as three tokens; strings.xml is now U+2019 throughout.
Verified on the emulator against a desktop visor added with the exact one-liner
the app shows. With Fleet off the config carries dmsg_ingest false, the log has
the HTTP server and no dmsg listener, and the desktop visor failed twenty-one
dials. Turning it on restarted the core and the peer connected eight seconds
later. Restart answered 202, the desktop unwound all thirty-seven modules and
re-entered startup 0.05s later, and uptime in the app went from 2h 29m to
2m 31s. kill -9 left the row Offline with its snapshot age and both actions
disabled. Toggling back off is symmetric. Not covered: a second remote visor,
and offline timing on a real device.
… the lock

  The Settings tab was the last placeholder. It holds the four things that are
  about the phone rather than about an app, plus the theme override and the
  version card.

  `config gen` has no `--sk`. Its `-r/--regen` takes the secret key from the
  config it is about to overwrite, so installing a key means writing it into
  that file first and letting the regenerate read it back; everything else is
  the first-run pipeline unchanged, with `mergeExistingApps` keeping per-app
  argv for free. The key is validated before anything is touched, and that is
  not politeness: handed an SK whose public half will not derive, `config gen`
  does not fail, it silently generates a fresh random keypair. A mistyped paste
  would land the user on a brand-new identity with no error anywhere. So the
  pasted key goes through `config pk` first, which both validates it and derives
  the public key — which is then what the confirmation quotes, so the user
  approves the actual outcome rather than a promise. New identity deletes the
  config instead of regenerating one, so the next start runs the untouched
  first-run path: one pipeline for a new identity, not two.

  Both operations clear local_path — chat history, app work dirs, transport
  logs. A visor's key is its identity, and carrying a previous identity's
  messages under a new one puts a conversation on screen that nobody can
  continue. It also makes the warning true: both flows are confirmed twice, and
  the first dialog says exactly what is lost rather than saying "destructive".
  users.db stays, being the app's own device credential and not part of the
  visor's identity. One bug avoided: VisorApi caches this visor's key for the
  life of the process and every /api/visors/{pk}/… route is built from it, so
  after an identity change the cache addresses a visor that no longer exists and
  every call 404s until the app is killed.

  The one Go change is a CLI bug this surfaced. getInterfaceNames() called the
  standard library's net.Interfaces(), which Android 11+ denies unprivileged
  processes, and it runs at flag-registration time — on every invocation of the
  binary — so on Android it put "Error: route ip+net: netlinkrib: permission
  denied" on stdout ahead of the output of whatever command was actually asked
  for. Harmless until something parses that output, which `config pk` now does.
  It uses anet.Interfaces(), which is what the rest of the repo already uses for
  exactly this reason and is a straight pass-through off Android.

  The lock is BIOMETRIC_STRONG | DEVICE_CREDENTIAL on API 30+ and
  BIOMETRIC_WEAK | DEVICE_CREDENTIAL below it — not a preference: the strong
  pairing is rejected outright on API 28-29, and the weak one is what androidx's
  own deprecated setDeviceCredentialAllowed resolves to there. Fingerprint or
  face when one is enrolled, the device PIN otherwise. MainActivity is now a
  FragmentActivity because BiometricPrompt hosts itself in a fragment; nothing
  else changed, since FragmentActivity is a ComponentActivity and only
  AppCompatActivity would have demanded an AppCompat theme. The lock is drawn
  over the app rather than in place of it — composing the navigation tree only
  while unlocked would tear down the back stack and the embedded chat WebView on
  every glance at a notification — and what keeps content from leaking anyway is
  FLAG_SECURE, set for the whole session while the lock is on: the recents
  snapshot is taken as the app leaves, before it is locked and with the last
  screen still on it, so blocking it has to be a flag that was already set.
  Screenshots go with it, and the setting says so. Two decisions the obvious
  implementation gets wrong: the state starts locked, because a fresh process is
  exactly the case that must ask, and a ringing or connected call is shown
  through the lock, because a call screen holds no secrets and every dialer does.

  Logs & diagnostics is the aggregate home of the shared viewer. Sources are
  named product first, process second — "SkyVPN (vpn-client)" — because the list
  is otherwise four process names and the process name is the part you need
  there: what the config calls it, what the API route is keyed on, what a log
  line says. The viewer's own app bar shows the product name alone, since
  "SkySOCKS (skysocks-client)" does not fit a centered title. Export all writes
  the config redacted: a diagnostics bundle is a thing people attach to an issue
  and the config carries the secret key, so sk is stripped and the full file has
  its own deliberate export behind a biometric check. Nothing fails the export
  either — a source that cannot be collected becomes a line in
  collection-notes.txt, since the bundle is most wanted when things are broken.
  The log level is written from the phone's preference on every launch, like the
  transport order, and read once while the visor builds its module graph, so
  changing it restarts the core.

  Core version comes from the running visor's summary rather than
  `libskywire-mobile.so --version`: the binary would answer with the core down
  too, but the CLI writes to stdout before any command runs, and scraping it
  means parsing whatever else happened to be printed that launch.

  Verified on the emulator (Pixel arm64, API 37). An all-zero key was refused in
  the core's own words with nothing touched; …0001 derived 0279be667e…16f81798
  in the confirmation, and after the two dialogs the config carried that keypair,
  local/ was recreated empty, and Home reported Connected under the new key —
  which is also the proof that the API client dropped its cached identity. New
  identity produced a fresh keypair with every phone-profile pin surviving the
  regenerate. The exported config is byte-identical to the on-device file. Export
  all wrote 10 files with sk absent and pk present, no collection notes, and
  stopped apps' feeds as empty files rather than errors. The log level saved
  silently with the core down and asked first with it up; its five chips wrap to
  two rows, the first cut put them in a Row and TRACE rendered one letter per
  line. With a device PIN set, enabling the lock asked first and screencap has
  returned black ever since; cold start raises the prompt, away 5s returns
  straight in, away 40s asks again. Dark and light both rendered.
…t post itself

  Two things a voice message on a phone needs and did not have: a way to speed it
  up, and a way to pause it without going back to the tab it is playing in.

  Speed is a pill on every player's action row — voice messages, round video
  messages and attached audio/video alike, one control in four places. It cycles
  0.75x -> 1x -> 1.25x -> 1.5x -> 2x rather than opening a menu: one tap for the
  common move, it wraps round to get back, and the label reads the rate it is at
  rather than the one it would move to. The stops run below 1x as well as above,
  because slowing down is what you want for a clip you are transcribing or a
  video you are looking for a detail in, and both directions cost nothing on the
  way round. One rate for the whole page, kept across reloads — not per clip:
  someone who listens at 1.5x wants the next one at 1.5x too, and a speed that
  resets every message is one you set once and then stop using. Off 1x the pill
  fills in, so a clip playing at a speed chosen three messages ago says so.

  The rate is set on defaultPlaybackRate as well as playbackRate, and re-applied
  at loadedmetadata and at play. The media load algorithm resets playbackRate to
  defaultPlaybackRate, so a rate applied to an element that has not fetched its
  metadata yet is quietly lost — and that is every player on the page, since they
  all preload metadata only and most never get further until they are tapped.

  The notification is the part that could not be done in the page, and the reason
  is worth recording. The web already has the right API — navigator.mediaSession
  is what puts a playing clip on the lock screen and under the media keys — and
  Android WebView implements it and connects it to nothing. Measured on-device
  while a voice message was playing from the embedded page: dumpsys media_session
  reported "Sessions Stack - have 0 sessions" at the same moment dumpsys named
  this package as the one making sound. The bridging code lives in
  Chrome-the-browser, not in WebView. So the page reports what it is playing
  across a JS interface and the app posts the session and the notification itself
  (ChatMedia). The mediaSession calls stay exactly as they are — they are what
  works when the same UI is opened in a real browser, and they cost nothing here.

  No service of its own. SkywireCoreService is already a foreground service
  whenever there is a chat to play from, so the process is foreground and its
  audio is allowed to keep going; a media service on top would be a second
  always-on notification for something that exists only while a clip plays. The
  notification goes when the WebView does, because the page IS the player and a
  shade full of controls for a destroyed one is buttons that do nothing.

  Position is pushed on the events that change something — play, pause, seeked,
  ratechange, durationchange — and not on timeupdate. The session carries a
  position and a rate and the system extrapolates the scrubber from the two, so
  pushing four times a second would be four notification rebuilds a second for a
  bar that was already moving. The rate rides along for the same reason it
  matters to the audio: a clip sped up in the page and left at 1x in the session
  would drift visibly against itself. An unknown duration is sent as -1 rather
  than 0, which draws no scrubber instead of one that cannot move — a clip
  recorded in the composer has no duration in its header until the page has
  scanned it. Artwork is decoded only from the data: URL the page already had on
  screen; fetching over HTTP from here would need the chat's own credential, and
  a missing thumbnail is not worth that.

  Verified on the emulator (Pixel arm64, API 37), against a voice message
  recorded in Saved Messages:

  - The pill cycles and sticks: set to 1.5x, still 1.5x after an app reinstall,
    and applied to a clip recorded afterwards.
  - Session published where there was none. Before: "Sessions Stack - have 0
    sessions" with the app named under Audio playback. After: "Sessions Stack -
    have 1 sessions: SkyChat com.skycoin.skywire/SkyChat/2", state=PLAYING(3),
    speed=1.5, metadata description "Voice message, Saved Messages, SkyChat" —
    title, thread and album as intended, artist taken from the chat header so it
    reads correctly for a DM, a group and the notes thread alike.
  - Notification on its own channel: channel=chat-media, category=transport,
    actions=3, vis=PUBLIC. The system media player renders it with a live
    scrubber and the output switcher.
  - Round trip both ways: pause from the shade paused the page at 4593 ms; play
    from the shade resumed it from 4593 ms.
  - The transport receiver delivers (Broadcast completed: result=0).

  Not covered: the two 10-second seek buttons. They travel the same path as play
  and pause — the same evaluateJavascript into app.mediaAction, differing only in
  which branch runs — and the receiver was confirmed delivering, but the test
  clip's source had gone stale before a position change was on record. The
  desktop-browser half of this (where mediaSession works on its own) is untested
  for want of a browser to test it in.

  Found while testing, not fixed here: a file or recording sent to Saved Messages
  fails with "bad pk: Invalid public key". sendMessage() checks isSaved() and
  keeps a note local; _uploadFile() has no such check and POSTs /send-file with
  pk=saved, which the server cannot parse as a public key. It is also why older
  clips there read "transfer failed" — nothing persisted the bytes, and the bubble
  plays from a blob URL that dies on reload. The fix needs somewhere real for a
  note-kept recording to live, which is its own change.
…t isn't one

  Attaching a file or recording a voice message in Saved Messages answered "File
  send failed: bad pk: Invalid public key". The notes thread is a conversation
  with no peer, and sendMessage() knows it — a text note takes the local path and
  never touches the wire. _uploadFile() had no such branch, so it did what it does
  for every other thread and put the destination in the pk field. The destination
  was the thread's own name, "saved", which the server can only reject. resendFile
  carried the identical line, so the Retry button reproduced it exactly.

  The server gets a third destination: `local=1` keeps the bytes and offers them
  to nobody. It reuses saveSentCopy and sentCopyURL — the mechanism that already
  exists so a file you SENT survives a reload — so a note's attachment lands under
  the same name in the same store and comes back as /files/<id>.<ext>. That choice
  is the whole reason this is a small change: the player, the waveform decode,
  /thumb, download and retention all treat a note's file as they treat any other
  kept file, because it is one. It also matters for the obvious failure the old
  code had underneath the error: the browser renders its own attachment from a
  blob: URL, which dies with the page, so even a note that had somehow been
  accepted would have come back as a bare file card. The branch is deliberately
  independent of the transfer manager — keeping a file for yourself has no reason
  to require that file transfer is enabled at all.

  On the page, the destination choice now lives in one place, _fileDest(), used by
  both callers. That is the actual repair. The bug existed twice because the choice
  was written twice, and the third caller would have got it wrong too.

  A note's file carries status 'stored' — kept here, offered to nobody, so there is
  no delivery to confirm afterwards — which renders one tick, the same as the text
  notes it sits among. The toasts say "Saved." rather than "sent", and a failure
  says "Could not save that file" rather than reporting a send that was never
  attempted.

  The paperclip is back in that composer, which is the other half of the same
  mistake. selectSaved() hid it on the reasoning that nothing leaves this thread,
  so the affordances that ship bytes somewhere have nothing to do here — right when
  a file there could only be sent, and precisely why the only way to reach the bug
  was the mic button, which was never hidden. Now that a note can keep a file,
  attaching to Saved Messages means "keep this with my notes", which is worth
  doing. The network picker stays hidden: a note has no network to choose between.

  Verified on the emulator, against the embedded UI in the Android app:

  - A voice note records with no error, gets a player and a single tick, and shows
    a download link — which renders only for a /files/ URL, so the copy is real
    and not the blob.
  - It survives a full app restart and page reload: still a player, and still
    plays — state=PLAYING(3), speed=1.5, "Voice message, Saved Messages, SkyChat".
  - The copy is on disk where a sent file's copy goes:
    skychat-downloads/fea5690c8b99f697.weba, 90,667 bytes.
  - go test ./cmd/apps/skychat/... passes, with a new
    TestSendFileHandler_LocalKeepsWithoutSending covering the 200, the file_url
    shape, the bytes on disk and a missing source giving 500 — plus a
    "no destination at all" 400 case the branch test was missing.

  Not repairable: the "transfer failed" bubbles already in that thread. Their
  bytes were never kept server-side, so there is nothing to point them at. They
  stay as the record of the failure; new ones will not join them.
…e the phone

  The Wallet tab is no longer a placeholder. One surface holds three kinds of
  chain: Skycoin, any fiber coin the user adds by naming it and pointing at
  their node, and Bitcoin mainnet. Everything that touches key material — seed
  generation, derivation, transaction construction, signing — happens on the
  phone; the network is asked for balances, history and broadcast, and nothing
  else.

  The decision the wallet hinged on was how to run Skycoin's crypto on
  Android. A gomobile binding would have meant a second Go artifact beside the
  visor payload and key material crossing a JNI boundary. Instead the needed
  slice of the reference implementation is ported to pure Kotlin in a new
  :wallet-core JVM module — no Android types anywhere in it, so every byte of
  the money-handling code runs under host-side unit tests. And the tests are
  the point: the port is held against the reference implementation, not
  against itself. The cipher testsuite's golden seed files must reproduce
  secret, public and address chains, and every stored signature must recover
  to its stored key. A Go generator run against the reference repo emits five
  transaction.Create fixtures — change, multi-input, send-all, the
  exact-amount case that forces the extra-input hour recovery, a zero-hour mix
  — and the Kotlin port must match the chosen inputs in order, every output's
  coins and hours, the inner hash and the byte-for-byte serialization. The
  BIP143 example's signature is reproduced byte for byte, which pins down the
  whole Bitcoin signing stack, since the BIP's own example is RFC 6979.
  Eighteen tests, all green.

  One deliberate deviation from the reference: signatures are RFC 6979
  deterministic rather than random-nonce, then low-S normalized with the
  recovery id flipped to match. The chain verifies recovery and malleability,
  not nonce provenance, and a phone RNG is the one component in this stack
  with a track record of losing coins.

  Fiber coins cost no code per coin. Every fiber chain runs the same daemon,
  so one node client and one WalletCore implementation serve them all,
  parameterized by URL — and burn factor, decimal precision and the size cap
  are read from each node's /api/v1/health at plan time, so a chain with
  different rules is honored without an app update. This is also why the
  network security config moved from a loopback whitelist to base-allow
  cleartext: the shipped Skycoin node is plain HTTP and user-entered fiber
  nodes are free-form addresses that cannot be whitelisted by domain. Nothing
  secret rides those connections; signing is local and chain data is public.

  Seeds are sealed with a new AndroidKeyStore AES-GCM key, separate from the
  service-password key — coins and passwords must not share a blast radius —
  and deliberately not auth-bound: derivation legitimately runs without a
  prompt, and an auth-bound key silently bricks the seed the day the user
  removes their screen lock. The gate is at the flow level instead: every send
  and every reveal goes through the shared biometric confirm, and the prompt
  states the consequence before authentication, never after. Backup, restore
  and reveal hold FLAG_SECURE for exactly their own lifetime, through a helper
  that on dispose respects the app-lock preference already holding the flag.
  Wallet metadata stores derived addresses — they are public — so opening the
  app never decrypts anything.

  The screens follow the wallet design set: coin chip above the balance, a
  per-chain sub-line (Coin Hours for the Skycoin family, confirmed outputs for
  BTC), and a Send screen where only the fee card changes between chains —
  hours burned and remaining on one, sat/vB presets and a slider on the other.
  The review sheet shows the exact planned figures, and names the coin's own
  network: Skycoin is the original chain and fiber coins are separate chains
  built from its codebase, so "Skycoin fiber network" was wrong copy and is
  gone. Each wallet grows addresses from its seed on demand — the Receive
  sheet generates the next one for SKY and fiber chains and for Bitcoin's
  BIP84 receive chain alike, with BTC change addresses derived internally,
  fresh per send. When the node stops answering, the tab keeps showing the
  cached numbers under an amber banner naming when they were last true, and
  Send is disabled: a wallet that cannot reach its node can still be read,
  just not spent from.

  Verified with real coins on the production network, from an emulator against
  node.skycoin.com: created a wallet (quiz rejects a planted wrong word by
  position; the seed screen screencaps black), received 2.000 SKY from a real
  wallet — balance, hours, green row, Confirmed pill — and sent it back
  through the app with Max. The fee card projected a 2,725-hour burn, exactly
  ceil(27,243/10); the review sheet promised burn 2,725, balance after 0.000,
  hours after 0; the PIN prompt gated signing; and the chain confirmed
  bc65baea…b98aedeb with precisely those numbers. The counterparty confirmed
  receipt. Bitcoin: wallet created, mempool.space answering, live fee presets,
  fresh bc1q address accepted by the API; the spend path is vector-proven but
  no real BTC was moved. Offline: the stale banner, disabled Send and cached
  history behaved as designed, and recovery was silent. Not covered: a fiber
  chain with non-default verification parameters, and restore-scan against
  deep address usage.
… one header

  SkyChat's UI is one file embedded in the Go binary and shared with the
  desktop, and it was a single dark theme on its own token set — slate greys, a
  cyan accent, green section headings — sitting next to a Wallet built from the
  design bundle. Divergence showed the moment the two were on screen together,
  so the wallet tokens are now the app's tokens. The old names survive as
  aliases pointing at them, which is what let the whole surface re-skin at once
  instead of rule by rule.

  Both themes, and the phone picks. Light arrives through prefers-color-scheme,
  which is right in a browser and wrong inside an app carrying its own
  Light/Dark/System setting: a phone set to System-dark with the app pinned to
  Light was opening a black chat inside a white one. The host names the theme in
  the URL and a script in <head> reads it before the stylesheet is reached, so
  nothing flashes. applyTheme sets the same attribute on a live document for the
  recompositions that keep the WebView; the Activity declares no configChanges,
  so a system uiMode change recreates it and takes the URL path instead.

  Type is the Skycoin face, shipped rather than assumed — both cuts now sit in
  the app's static dir, so the page needs no network to look right and the
  desktop gets the brand face too. The family has no 500 and an unpinned Medium
  resolves silently to Regular, so every weight here is 400 or 700. Public keys
  leave monospace: the Wallet sets addresses in the brand face and these now
  match.

  Icons were emoji pasted in as HTML entities. Three problems, and the second is
  the one that mattered: they are drawn by the platform's font, so the same
  button was flat on one machine and a glossy cartoon on the next; they ignore
  currentColor, so a menu row that tinted itself orange to say "muted" kept a
  yellow bell; and they sat at a different weight and baseline from the SVGs the
  file already had. Now 49 glyphs in one table on one grid behind icon(name,
  size). A missing name returns '' rather than throwing — an icon is decoration
  and a typo in one must not take a menu down. Four plain-text strings lost their
  glyph instead of gaining a drawing, since no markup reaches them; one of those,
  _msgPreview, rides inside a reply and is protocol-visible.

  Every tab but Home now opens under the same header: back at the left, the name
  centred, a circled ? at the right, the two round buttons the same size so the
  title sits still as you move between screens. Back on a tab root goes to Home.
  The ? took the place of the per-screen Logs action on SkyChat, SkySOCKS, SkyVPN
  and SkyDEX, and each help text says where the logs went — a log viewer is not
  wanted from the screen being used, it is wanted when something is wrong, and
  then all of them are wanted, which is the list Settings has always kept. Fleet
  keeps its own per-visor Logs, because that feed is a remote machine's arriving
  over dmsg and Diagnostics only knows about this phone; its ? still opens a
  sheet rather than a dialog, since its guidance ends in a command with a copy
  button. SkyChat's overflow menu is gone with Logs — it held nothing else, and a
  wedged page reloads from the Retry the error state already offers.

  One bug fixed on the way. The recording lock pill, the target a finger slides
  up onto to go hands-free, was pinned at right:20px while the composer row's own
  padding was 16px on a desktop and 12px on a phone — a 40px pill 4px and 8px to
  the left of the 40px button it is meant to sit above, which on a phone is a
  finger sliding past it. The row names its padding now and the pill and the
  video self-view are inset by it. The lock threshold is a plain 56px of vertical
  travel and never hit-tested the pill, which is why the gesture kept working
  while looking wrong; the phone misalignment predates this redesign.

  Verified on the emulator against a fresh core and APK. Chat opens under the new
  header and renders the redesign — drawn QR, address-book and gear marks, the
  Connected pill, filter chips, a grey CHATS label, no scrollbar. Setting Light
  and returning renders the page light with every icon inverting on
  currentColor; the app was pinned to DARK first, so a system night-mode flip
  correctly did nothing. A conversation shows blue bubbles with the tail corner,
  drawn ticks, the waveform player and the touch sheet. A temporary test
  confirmed both OTFs and the page reach the embedded FS. Not covered: the
  desktop chat against this build, and applyTheme separately from the URL path.

  The icon set is ours, not the design team's — android/icon-brief.md is the
  brief for replacing it.
…e on it

  Four things carried as open questions since the size work settled, plus one
  thing the audit turned up.

  Nothing this app stores leaves the device by itself. allowBackup=false was
  already refusing Google Drive; it does not refuse the other exit, the
  device-to-device transfer that runs when a new phone is set up from an old
  one, which would have carried the visor's secret key and the wallet's twelve
  words onto a second handset. Both are declined now, and declined again for
  Android 11 and earlier, which ignores the newer file — minSdk is 26, so that
  is real devices rather than a formality. The rules exclude everything rather
  than naming the two files that matter: narrower would have been correct today
  and wrong the first time someone adds a third store. Nothing is lost by it.
  The identity is meant to move deliberately, through Export config, and the
  wallet is meant to come back from its phrase.

  The config can now be encrypted at rest. It holds sk, which is the whole of
  this phone's identity, and the threat is not another app — app-private storage
  already handles that — it is the device being examined. Sealed, it is
  AES-256-GCM under a key the keystore will not hand out, so a copy of the
  filesystem is not a copy of the identity. Two files, never both meaningful:
  plaintext while the visor runs, because it opens the path it is given and
  rewrites it; ciphertext while it is stopped.

  The sealing happens after the visor exits rather than at generation, which is
  what lets its own rewrites survive — whatever it left behind is what gets
  encrypted. It runs in the finally, under NonCancellable, so a crashed core
  still seals and a cancelled scope cannot skip it and leave the key in the
  clear. Write the ciphertext, read it back, then delete the plaintext: a power
  cut in the middle leaves both files, which unseal resolves in favour of the
  plaintext. The outcome is never a config that is gone.

  There is one failure this could not be allowed to have. ensureConfig treats a
  missing config as first-run and generates a NEW identity, so a sealed config
  plus a caller that forgot to unseal would look exactly like a new phone and
  replace the user's key without a word. The unseal therefore lives inside
  ConfigManager, on every path that touches the file, rather than in the service
  that happens to call it. The two synchronous readers go through it too, so the
  identity screen still shows a public key with the config sealed and the core
  down — which is precisely when that screen is being read.

  Off by default, and staying optional. It buys nothing against a running
  unlocked phone, and it cannot be recovered if the keystore is wiped — a
  factory reset, or on some OEMs a screen lock removed and set again. The
  Settings copy says exactly that. Turning it off is confirmed biometrically:
  that is a security decision being reversed. Turning it on is not.

  The foreground service was never what Doze does to us. Doze suspends the
  network of apps that are not exempt once the screen has been off a while, and
  a foreground service does not opt out of it. A short nap costs nothing — dmsg
  sessions are TCP with their own keepalives — but over a long idle they drop
  and what the user sees is messages arriving when the phone next wakes instead
  of when they were sent. The exemption is offered twice: a card in Settings
  that stays, and a prompt on Home that appears only once the core is actually
  running, because before that it is a question about a problem the user has not
  got yet. Not now is remembered for good and silences both.

  The audit found one gap. FLAG_SECURE covered the four wallet screens that show
  or take a recovery phrase, and the app lock holds it session-wide when it is
  on. It did not cover Settings > Replace secret key, which is a field you paste
  a visor secret key into in the clear. That is the same class of secret as the
  twelve words — it is the identity, it cannot be reissued, and whoever reads it
  owns the visor. It has the flag now, and SecureWindow moved out of the wallet,
  which it stopped being a concern of.

  Measured rather than assumed: the core payload is 63,963,432 bytes against the
  83,886,080-byte budget the CI lane enforces.

  Verified on the emulator, and the one that mattered is the round trip. Sealing
  turned 9,513 bytes of plaintext into 9,541 bytes of ciphertext with no
  plaintext beside it — plaintext plus a 12-byte IV plus a 16-byte tag, exactly
  — and strings finds nothing in it. Connecting restored 9,513 bytes and the
  public key was unchanged, which is the no-new-identity property. Disconnecting
  sealed it again. Settings kept showing the key throughout. The battery prompt
  appeared once connected on an emulator confirmed absent from the deviceidle
  whitelist. The secret-key dialog screenshots as a pure-black 21 KB PNG where a
  normal screen is 140 KB.

  Not covered: device-to-device transfer cannot be exercised on an emulator, so
  that exclusion is verified by declaration and not by observation; Doze was
  reasoned from the platform contract rather than measured over a long idle; and
  the keystore-wiped path is handled with a stated error but has not been
  provoked.

  Also: the bottom bar's Skycoin cloud goes 44dp to 56dp. It carries no label,
  so it has the label row to grow into, and it is the one slot people aim for by
  shape rather than by reading — at 44 it read as a slightly large icon among
  four icons instead of as the centre of the bar.
…d in the middle

  The design mock is now the whole app's visual language. Primary moves to
  #0F7BF4 with one gradient pair defined in the theme — heroes on the deep
  three-stop, round primary actions on the lighter one. Light is the native
  theme: white, cards in blue-tinted near-white behind a hairline border,
  because at that fill the border is what makes a card a card. Dark is the
  same hues on deep navy rather than grey or black, with a brighter primary
  carrying dark ink — a light source, not a stain. The stray status greens
  and ambers that had grown in five places now live once, in SkyAccents.

  Quicksand and Nunito replace the Skycoin typeface, one variable file each:
  Quicksand Bold for everything that names, Nunito for everything that
  explains (SemiBold) and operates (Bold). The old family shipped no 500
  cut, which is why some sixty screens had hand-bolted Bold onto body roles;
  those overrides are now harmless rather than load-bearing.

  The Material NavigationBar gives way to the design's floating shell:
  icons only, the active tab in its filled Rounded form, and the Skycoin
  cloud on a gradient disc riding above the bar, ringed in the shell's own
  color and breathing a slow pulse — the one piece of ambient motion, on the
  one control always worth a look. The lift is an offset, which moves
  drawing and hit-testing but not measurement, and Scaffold places the
  bottom bar last, so the jut wins the overlap without reserving dead space.

  The hub is now a dashboard rather than a launcher. A local summary poll
  (nothing crosses dmsg) feeds the header's "installed · running" line and
  every card's status dot. Category chips filter the grid. SkyVPN is never
  a tile: it is the hero card at one fixed size, on or off — the stats
  cells read "—" instead of leaving — with the exit spelled as a person
  would say it, flag and country by name. Its switch stops the tunnel
  directly, and starts it from the hub only when nothing about starting
  needs a screen: consent already granted, an exit already saved. The
  preference keys both owners read moved to VpnArgs, where they can only
  be spelled one way.

  Two findings worth keeping. contentColorFor(surfaceVariant) resolves to
  onSurfaceVariant, which had quietly muted every card title in the app —
  cards now pin their content to ink, the muted role is darker than the
  mock's caption grey because here it carries prose, and body text sits at
  SemiBold; all three were one user-visible symptom, "faint". And this M3
  revision draws OutlinedButton borders with outlineVariant — a hairline in
  this palette — so every standalone action became a filled tonal button;
  dialogs and inline links keep their conventions.

  Verified on the emulator in both themes: Home, hub, Wallet, Settings
  screenshots; tab selection; the hero card off-state with a named exit;
  a live core under the new Home connect disc. Known gaps, recorded in the
  report: the embedded SkyChat page still wears the previous palette (it
  lives in the Go .so and receives only a light/dark boolean), and SkyDEX's
  injected phone CSS keeps its dark-only assumption.
…them fleet-wide

  The 5-minute uptime heartbeat we had flagged is not the thing. It is twelve
  remote wakes an hour against the dmsg keepalive's hundred and twenty, it
  cannot be disabled by config anyway — resolveUptimeTargets derives the TPD
  URL from transport.discovery, deliberately independent of uptime_tracker —
  and with no wakelock anywhere in the app it does not fire during deep sleep
  at all. Surveying what else ticks turned up two jobs that earn nothing.

  The dmsg client republished its discovery entry five times too often, on
  every visor in the fleet. dmsgc.New builds a Config literal and never sets
  UpdateInterval, so EntityCommon.init falls back to DefaultUpdateInterval —
  one minute, which is the server's cadence, because a server's
  AvailableSessions changes on every client connect. A client's entry carries
  only its delegated servers, which on a settled client never change, and
  DefaultConfig sets five times that. It is simply never consulted: eleven of
  the fifteen client call sites build the literal. Nor does the periodic tick
  short-circuit — the SamePubKeys guard suppresses only nudge-driven updates,
  and the due timer always makes due true — so each one was a signed GET+PUT
  to dmsg-discovery over a fresh dmsg stream, every minute, forever.

  The default moves into Config.Ensure, which NewClient already calls before
  init reads the value. Fixing dmsgc alone would leave ten literals wrong and
  the next one would reintroduce it; Ensure is documented as the place that
  ensures config values are set. The dmsg server keeps its own ServerConfig
  and its own one minute. Three tests pin the invariant, because what let this
  live was that nothing asserted the interval.

  tpviz runs on the phone and nothing on the phone can reach it. It is built
  whenever a hypervisor has a local visor and started by startUI, which the
  phone must call — that is the localhost API the app talks to. Start costs a
  geoip and cache fetch at boot, a 4m30s SD/DMSG refresh over dmsg-HTTP and a
  2s websocket ticker, backing routes this build cannot serve: it embeds no
  hvui. tp_viz.enable looks like the lever and is not one — written by the
  generator, read nowhere, and FillDefaults sets it only inside its
  DmsgDiscovery=="" branch, so an absent block cannot be told from an explicit
  false. Reading it is what produced the /tp-viz/ 404s the comment there warns
  about. So the gate is a build tag, matching the hypervisor_dmsg_ingest pair.
  Every use of tpvizServer was already nil-guarded, the root /api/* routes
  included, and the app calls none of them: its discovery lookups ride
  /api/svc-fetch, not tpviz's root-mounted /api/services.

  Verified on a host build of the mobile variant against a config carrying
  tp_viz enable:true, so the tag is doing the work. All six tpviz routes 404,
  /api/about, /api/service-health and /api/visors-summary serve 200, no tpviz
  line in the log. For the cadence, with the -i-forced lan_dmsg_server removed
  as the phone removes it — it is a server entity and republishes at a genuine
  one minute, which masks the client — four [dmsgC] updates in the first 37
  seconds, then one tick at 5m01s where the old build gave five. Both tags
  build, android-mobile-check passes at 63,963,432 bytes against the 80 MB
  budget, dmsg suite green.
  Its keys are not decoration. They authorise their holders to fetch this
  visor's log server, its system survey and its pprof over dmsg —
  initDmsgHTTPLogServer builds one allow-list out of them and forward_proxy
  uses the same set. On a fleet node that is the point: it is how an operator
  inspects machines they run. A handset is not one of those machines. Nobody
  deploys a phone, the survey exists for reward eligibility, and this build
  sets no reward address, so the keys buy the owner nothing and each one is a
  party that can read the device.

  Generation was writing seven. They are embedded deployment defaults rather
  than the conf-service fetch, so the --nofetch the app already passes never
  suppressed them.

  Two sites, and the second is the one that makes it hold. config gen writes
  the field, and startConfigRefresh re-reads the key sets hourly and
  Empty by default, not empty always. Three ways in survive on purpose:
  --surveywhitelist at generation still applies, since only the deployment's
  keys are dropped and the flag's are kept; user_survey_whitelist is a
  separate field that EffectiveSurveyWhitelist merges and config refresh
  preserves; and the hypervisor keys Fleet adds are appended separately by
  initDmsgHTTPLogServer, so turning Fleet on still authorises the phone's own
  hypervisor. The visor's own PK is always whitelisted, so nothing local
  loses access.

  Verified with a control rather than an assertion: the same argv the app
  passes, run through both builds — desktop writes 7 keys, mobile writes
  none. Build-tagged tests pin the predicate on each side, because a silent
  flip would restore the keys in two places at once. Both tags build,
  android-mobile-check passes at 63,963,432 bytes.

  The refresh half is inspection, not end to end. With the interval
  temporarily shortened the loop demonstrably runs and reaches the path, but
  the conf service returned a prod block that would not parse, so neither
  build could repopulate and the comparison showed nothing; the timing change
  was reverted. What is unproven is that a false && short-circuits.
mrpalide and others added 12 commits August 7, 2026 10:45
…ile-v tag

  Two lanes, neither of which disturbs the desktop ones.

  Pull requests that touch android/ build the Go payload, run the wallet-core
  suite and assemble the debug APK, keeping it as an artifact for triage. A
  pull request that touches nothing there runs none of it. The Go half is
  deliberately outside that filter and is not duplicated: test.yml's android
  job already builds the arm64 payload and holds it to its size budget on
  every PR, which is what catches a Go change breaking the mobile variant.
  The payload is still built in this lane, because an APK assembled without
  the .so is not the artifact we ship and packaging is part of the check.
  LiveNodeTest is already behind SKYWIRE_NET_TESTS=1, so the suite is offline.

  Releases hang off a mobile-vX.Y.Z tag. The prefix is what keeps the lanes
  apart: release.yml fires on v*, which mobile-v1.0.0 does not match, so
  tagging the phone never starts a Skywire release. The tag is the version —
  1.2.3 becomes versionName 1.2.3 and versionCode 10203, passed as gradle
  properties, with the committed values left as the fallback so a local build
  still needs no arguments. Minor and patch above 99 are rejected, because the
  scheme stops being monotonic there and both stores require the code to only
  ever climb. The payload is the NDK/cgo lane rather than the pure-Go one CI
  and the emulator use: cgo resolves through bionic's getaddrinfo, the only
  path that honours the phone's real resolver configuration.

  On signing the workflow refuses rather than improvises. assembleRelease has
  always produced an unsigned APK, and unsigned means uninstallable; the debug
  APK installs but is debuggable, which is not a thing to hand to users of an
  app holding wallet seeds. Neither is a fallback. The build takes its
  keystore from the environment — absent, release stays unsigned exactly as
  before, so make android-apk is unchanged — and the workflow stops at a
  preflight step, ahead of the ten minutes of build, printing the keytool and
  gh secret set lines it needs. apksigner re-checks afterwards, which is also
  what catches an unsigned build: its filename differs, so the expected path
  is simply missing. Four secrets are required and not yet set; the key behind
  them has to be made once and kept, since Android will not upgrade an app
  signed with a different one.

  Notes range over the previous mobile-v tag. Left alone --generate-notes
  walks back to whatever tag came last, usually a desktop release, and the
  changelog would be every Skywire commit since.

  Verified by rehearsing the release path rather than reading it: a throwaway
  keystore, assembleRelease with the properties the workflow passes, then the
  workflow's own verification. apksigner reports Signer skycoin#1, aapt2 reports
  versionCode 10203 / versionName 1.2.3, and the APK is not debuggable. With
  the keystore variables unset the same command still yields
  app-release-unsigned.apk, so the documented local path is untouched — its
  Makefile help now names the variables. Both workflows parse. Keystore and
  rehearsal APKs deleted. Neither workflow has run on GitHub yet; the
  runner-provided ANDROID_HOME and ANDROID_NDK_LATEST_HOME are asserted with
  guards rather than assumed.
…onCode

  `make android-apk` had no way to stamp a build, so a maintainer wanting a
  signed local APK either edited build.gradle.kts or reached past make to
  gradle with the two -P properties the release workflow passes. Both are the
  kind of thing that drifts from what CI actually does.

  APK_VERSION=X.Y.Z now stamps it, and the versionCode formula lives here and
  nowhere else — the release workflow calls this target instead of computing
  its own, so a tag build and a local build cannot disagree about the number.
  The workflow keeps its own check of the tag's shape, because failing in
  seconds on a malformed tag beats failing after the payload build, but the
  arithmetic has one home.

  0.0.0 is rejected with a reason. It derives versionCode 0 and Android
  requires a positive integer, which AGP enforces at configure time — the
  error is clear enough but arrives seven hundred milliseconds and one
  gradle startup later than it needs to. The lowest usable version is 0.0.1.
  Minor and patch above 99 are refused for the older reason: past that the
  scheme stops being monotonic.

  The target also says what it is about to do — the version and code it
  resolved, and whether it found a keystore — because the difference between
  a signed build and an unsigned one was otherwise only visible in the output
  filename, after the fact.

  Verified by running it: APK_VERSION=0.0.1 with a throwaway key produces an
  APK reporting versionCode 1 / versionName 0.0.1, apksigner confirms Signer
  skycoin#1, and it is not debuggable. 0.0.0, 0.0 and 1.100.0 are each refused with
  the reason. With no keystore in the environment the same command still
  yields app-release-unsigned.apk, so the documented local path is unchanged.
  Keystore and rehearsal APK deleted. The workflow parses; it still has not
  run on GitHub.
…an honest address

  Four things asked of the SkyVPN card and screen. Three were straightforward;
  the fourth could not be done as asked, and that is the part worth reading.

  Down and Up sat at zero because those fields were never going to work.
  upload_speed / download_speed are not derived from the byte counters: the
  route group exchanges them inside its ping/pong keepalive — handlePingPacket
  stores whatever throughput the far side announced, and the download figure
  is the remote throughput echoed back — so they want an active route group, a
  cooperating exit and a completed ping round. On a phone the pair never
  leaves zero. The SkyVPN screen had quietly known this for a while; its rate
  row was already behind a `> 0` guard and simply never appeared.

  bandwidth_sent / bandwidth_received do move, so RateSampler measures the
  rate here instead: bytes gained since the previous sample over the time
  between them, on elapsedRealtime so a clock correction cannot divide by
  something absurd and print gigabytes per second. A counter that went
  backwards means the app re-dialled, and the delta across that boundary is
  meaningless, so it ends the series rather than reporting it. Nothing new is
  asked of the visor — the numbers come out of a poll already being made.

  Route length is a control now. min_hops is a router knob: one hop allows a
  direct route, two or more forces intermediaries so no single node sees both
  who is asking and what is being asked. The card offers 1/2/3 in those words
  rather than a bare number, because "min_hops" decides nothing for anyone.
  setTransportPreference already documented why the PUT has to be
  read-modify-write — it applies every field, so sending one alone would also
  send min_hops: 0, which the router reads as routing disabled — and both
  setters now share one private updateRouterSettings so the next one cannot
  rediscover that. Changing it re-dials a running tunnel: the value only bites
  when a route is built, and an established tunnel would otherwise keep the
  hop count it was dialled with while the screen claimed a property the
  traffic does not have. Unlike the transport order, which the phone owns and
  re-pins every launch, this one belongs to the visor — it persists it, and
  the phone profile's routing edit starts from the existing object, so it
  survives.

  Killswitch state joins the card, read from the phone's own preference so it
  is right with the core down, which is when someone checks whether they are
  still covered.

  The before/after IP cannot be done the way it is normally done, and showing
  it anyway would have been a lie. SkyVpnService excludes this app's UID from
  the tunnel and has to: the visor is a child of the same UID and its dmsg
  traffic is what CARRIES the tunnel. So any probe the phone makes, from
  Kotlin or from the visor, leaves through the underlay whether or not SkyVPN
  is up — it would print the same address twice and read as broken. It is the
  same reason the desktop CLI's "Your current IP" does not port: that process
  is inside the tunnel; this app is not. The exit's address is not askable
  either. The handshake is ClientHello{UnavailablePrivateIPs} and
  ServerHello{Status, TUNIP, TUNGateway} — private 192.168.255.x addresses and
  a public key, with no field on either side carrying a public address.

  So the card shows the two things that are true: the address this device
  reaches the network from, which SkyVPN does not change, and the country the
  traffic leaves from, which is what does. The device address comes from
  Overview.public_ip, already on the wire — but it is not safe to print raw.
  The visor writes the NAT TYPE into that field when STUN fails and leaves it
  empty behind symmetric NAT, so publicIpOrNull gates it and the UI says
  "Shared carrier address" or "Not discoverable" instead of rendering the word
  "Blocked" as though it were an address. A line under the rows says why the
  device's own address does not move. A real exit IP would need a public
  address added to ServerHello on both ends with version-skew handling;
  deliberately not done here.

  Verified on the emulator against a live core. The hero card renders the
  address, hops and killswitch chips beside the stats row; the SkyVPN screen
  renders the Network address card and the Route length control. Tapping
  "2 hops" flipped the hint to the multihop wording and routing.min_hops in
  the visor's own config became 2; tapping back restored 1. The emulator is
  behind NAT, so the device address reads "Shared carrier address" — the
  symmetric-NAT guard doing its job on the first device it met. Not verified:
  Down/Up showing a non-zero number, which needs a tunnel actually carrying
  traffic to a reachable exit; the cells render "—" from the null path, the
  same code with no samples yet.
  The two-argument Locale constructor is deprecated, and it was the one
  warning in the release build that came from this session rather than from
  before it.

  Locale.Builder is not a drop-in: the old constructor is lenient and the
  builder is not — setRegion throws IllformedLocaleException on anything that
  is not a well-formed region. That matters here because the code is not ours.
  Service discovery hands back `??` when the visor's geo lookup could not place
  a node, and that would have gone from rendering a harmless literal to
  throwing inside a composable. Hence runCatching, with the bare code as the
  fallback: `??` on a card is honest, a crash is not.

  Checked against CA/NL/DE (Canada, Netherlands, Germany), `??`, `xyz` and the
  empty string. The malformed ones throw and fall back to the raw code, which
  flagEmoji already declines to prefix with a flag since they are not letters.
…ttery, hang-up, and the first message

    Six problems reported after using the app on a phone. Five were small and
    local. The sixth is a gap a peer-to-peer chat has and a server-backed one
    does not, and it is the part worth reading.

    The light theme's invisible controls were one bug, not a pale palette.
    Every case named — the voice-message play button, the call screen's mic and
    speaker — was white ink on a light surface. CallButton tinted every glyph
    Color.White, which is right for the two filled buttons (Answer green, Hang
    up red) and wrong for mic and speaker, which sit on surfaceVariant: a
    near-white card tint on the light theme. Those two take the theme's own ink
    and only switch to onPrimary once the blue fill is under them, and Speaker
    takes a different glyph off than on so its state survives being read by
    shape. In the chat page the player was color:#fff on rgba(255,255,255,.16),
    correct on a sent bubble — brand blue on both themes — and invisible on a
    received one. Ink and disc are variables now, set per bubble side, and the
    waveform canvas takes its colour from its own computed color rather than the
    hardcoded #f1f5f9 it had, dimming the unplayed half with globalAlpha so one
    value covers both states without mixing an rgba string for an unknown ink. A
    MutationObserver on data-theme repaints the players when the app's theme
    moves under them: CSS re-colours everything else on the page instantly, but
    painted pixels stay whatever they were. The in-call panel's filled states
    name the ink for the colour beneath them. Separately the light scheme's
    outline went from #7C8AA0 to #56657C — it is not only hairlines, it tints
    the bottom bar's resting icons, and at the mock's value those read as
    switched off rather than as not currently selected.

    The nav cloud sat inside the chat composer because it is drawn with offset,
    which moves painting and not measurement. The bar therefore measured as the
    shell alone and Scaffold handed screen content the strip the cloud was
    standing in. The bar reserves the lift itself now. Every screen loses 21dp
    it was never really allowed to use, and no screen has to know the cloud
    exists.

    Back left the app instead of stepping back inside it. SkyChat's header
    arrow called the tab's own back, so it threw the reader out of SkyChat from
    an open conversation while the phone's back gesture already walked the
    page's history; both run one rule now, page step first and leave only when
    there is none. SkyDEX gets the same plus a middle step — the trading page's
    own screens, since it is React and routes with pushState, then the market
    picker, then the hub. And the two hub tiles that are also bottom-bar tabs,
    SkyChat and Wallet, are pushed rather than switched to: going the tab way
    rewinds the stack to Home, which is why backing out of either landed on Home
    instead of on the hub it was opened from.

    Battery optimisation did nothing because openRequest was handed the
    Application by both callers, and startActivity from a non-Activity context
    throws without FLAG_ACTIVITY_NEW_TASK. The runCatching swallowed it, so
    Home's Allow silently did nothing and Settings reported a phone with no
    battery screen to open. One flag. Logcat now shows START
    REQUEST_IGNORE_BATTERY_OPTIMIZATIONS with flg=0x10000000 and result code=0,
    and dumpsys deviceidle whitelist lists the package afterwards.

    Hang up took seconds because the call screen is drawn off VoiceCalls, which
    a watcher fills from a two-second poll — so the red button did nothing
    visible until the next tick. Hang up and Decline remove the call from the
    shared state before the request goes out and suppress it until the visor
    agrees it is gone; a request that fails un-suppresses it and the poll puts
    the screen back. The watcher's delay is nudgeable now too, so the confirming
    poll happens at once rather than up to a tick later.

    The first message to a cold peer is the real one. A DM send has always
    dialled on demand, inside the first message's own request: for skynet that
    means planning and building a route, which can take the better part of a
    minute, and while it ran the sender saw a message sitting there with nothing
    to say for itself. So they sent another, and another — each starting a dial
    of its own — and concluded the app was broken.
    Back left the app instead of stepping back inside it. SkyChat's header
    arrow called the tab's own back, so it threw the reader out of SkyChat from
    an open conversation while the phone's back gesture already walked the
    page's history; both run one rule now, page step first and leave only when
    there is none. SkyDEX gets the same plus a middle step — the trading page's
    own screens, since it is React and routes with pushState, then the market
    picker, then the hub. And the two hub tiles that are also bottom-bar tabs,
    SkyChat and Wallet, are pushed rather than switched to: going the tab way
    rewinds the stack to Home, which is why backing out of either landed on Home
    instead of on the hub it was opened from.

    Battery optimisation did nothing because openRequest was handed the
    Application by both callers, and startActivity from a non-Activity context
    throws without FLAG_ACTIVITY_NEW_TASK. The runCatching swallowed it, so
    Home's Allow silently did nothing and Settings reported a phone with no
    battery screen to open. One flag. Logcat now shows START
    REQUEST_IGNORE_BATTERY_OPTIMIZATIONS with flg=0x10000000 and result code=0,
    and dumpsys deviceidle whitelist lists the package afterwards.

    Hang up took seconds because the call screen is drawn off VoiceCalls, which
    a watcher fills from a two-second poll — so the red button did nothing
    visible until the next tick. Hang up and Decline remove the call from the
    shared state before the request goes out and suppress it until the visor
    agrees it is gone; a request that fails un-suppresses it and the poll puts
    the screen back. The watcher's delay is nudgeable now too, so the confirming
    poll happens at once rather than up to a tick later.

    The first message to a cold peer is the real one. A DM send has always
    dialled on demand, inside the first message's own request: for skynet that
    means planning and building a route, which can take the better part of a
    minute, and while it ran the sender saw a message sitting there with nothing
    to say for itself. So they sent another, and another — each starting a dial
    of its own — and concluded the app was broken.

    The handshake is something the UI can ask for and talk about now.
    dm.Controller.Connect is Send's dial without the send; /link wraps it, where
    GET reports and never dials so polling is free, and POST starts one if
    needed and answers immediately, because a route must not hold a browser
    request open. The page asks when a conversation opens, says what it is
    waiting for in a bar above the composer, and holds what is typed meanwhile:
    the bubble opens as a new queued status, an amber clock sitting before
    pending in the monotonic ladder, and goes out in order, one at a time, the
    moment the link is up. A failed link says why and offers Retry and Send
    anyway, so a held message is never a trapped one; the same is true when the
    app has no /link at all, which stands the mechanism down for the session
    rather than holding messages for a handshake nobody is performing. A queued
    bubble that outlives its page becomes failed on reload, which is the state
    that has a Resend button.

    Verified on the emulator in the light theme with a debug APK over a fresh
    make android-mobile payload. The composer sits clear of the cloud; SkyChat's
    arrow returns to the conversation list from an open chat; the battery grant
    round-trips as above; a received voice bubble's play button and waveform
    resolve to #000 on #E4E7EC in light and #fff in dark, measured through the
    WebView's DevTools rather than by eye, with the sent bubble unchanged at
    white on blue; and a DM to an unreachable peer showed "Connecting to
    024ec474…58c7 over Skywire… · 1 message waiting" with the bubble on the
    clock, then "No route to 024ec474…58c7 yet — they may be offline." with
    Retry and Send anyway. Feeding the page a ready link drained the queue and
    advanced the bubble from queued to pending. go test over cmd/apps/skychat
    and pkg/skychat/dm is green, including new coverage for /link: that a GET
    does not dial, that malformed, empty and null keys and unknown networks are
    refused, and that auto prefers dmsg. pkg/skychat/group times out at 600s,
    confirmed pre-existing by running it on a stashed tree.
… tile

  numbers, chat file verbs + upload progress, a DEX list view, and heartbeats
  out of the room

  - Nav: the raised cloud gets its own navigateToHub() (pop-and-save, no
    restoreState) so it always lands on the services list — restoring the
    saved stack used to put the pushed screen straight back on top.
  - SkyVPN hero: exit line is flag + country + 6-char key prefix (the exit's
    IP is not knowable from the phone — sd geo carries no address and neither
    does the handshake); the device-address chip is gone; the killswitch is an
    icon-only state light, green armed / red not (new SkyAccents.dangerBright).
  - Hub tiles: SkyChat shows an unread badge fed by a new skychat /unread
    endpoint (the page POSTs its total, recordEvent bumps it per inbound
    between reports); Wallet's subtitle is the active SKY wallet's cached
    balance; Fleet's is "N visors connected" polled at the 15 s cadence the
    Summary-RPC fan-out tolerates.
  - SkyChat page: download / re-request moved off the bubbles into the
    per-message menu; /send-file and resendFile go over XHR with a live
    "2.1 MB / 10 MB" counter that completes, lingers, and fades; the link bar
    reads "Establishing connection with X over Skywire…".
  - Group heartbeats no longer render as messages: filtered in session
    replayHistory (the live path always did), at groupInbox.deliver, at
    GroupHistoryPage read time for stores written by older builds, and
    defensively in the page.
  - SkyDEX: injected Cards/List switch on every section — list rows show the
    primary pair and expand on tap; the market grid gets the same treatment.

  Verified: go build + group/visor tests green; chat scripts pass node
  --check; make android-mobile (64,028,968 B payload) and android-apk-debug
  build clean. Not yet exercised on a device.
  - wallet-core grows an eth package: Keccak-256, an RLP encoder, EIP-55
    addresses off the existing BIP 32 tree (m/44'/60'/0'/0/i), and EIP-1559
    type-2 build/sign — the existing low-S recoverable signature IS the
    Ethereum form, its recovery id doubling as yParity. transfer/balanceOf
    call data covers the whole ERC-20 surface the wallet needs.
  - EthRpcClient speaks JSON-RPC for state and broadcast, and the keyless
    etherscan-style Blockscout API for history — plain RPC cannot list an
    address's transactions.
  - EthWalletCore serves both the native coin and any token from one class.
    Amounts stay in the seam's 64 bits: ETH is carried in gwei and a token
    in its own decimals capped at nine; wei only exists inside the core as
    BigInteger. Token sends without gas ETH fail with the new
    WalletException.InsufficientGas, in words that name the actual problem.
  - App: ETH and USDT ship built in; the add-coin screen gains a
    fiber/ERC-20 toggle for user-added tokens (contract checksum-validated).
    A gas fee card that shows rather than asks, ETH/token review branches
    (no total row where amount and fee are different currencies), history
    fees always labelled ETH.
  - Verified: Keccak/EIP-55/derivation/RLP reference vectors, the EIP-155
    worked example reproduced byte-for-byte under RFC 6979, a type-2
    sign-and-recover round trip, and opt-in live tests parsing production
    publicnode + Blockscout answers (balance, history, balanceOf, tokentx).
    Full wallet-core suite and assembleDebug green. A funded on-device
    send/receive round trip is still owed.
…ery, coin logos

  - Launcher: adaptive-icon foreground 56x42 -> 44x33dp, fully inside the
    safe zone so no OEM mask crowds the cloud
  - Hub: wallet tile lists SKY · Fibercoin · BTC · ETH; killswitch chip is
    colored text again instead of the icon-only shield
  - Light theme: Transport and Route-length join SectionCard (hairline
    border, 22dp radius); card fill and container ramp brightened
  - Battery: re-read the exemption on every resume — the system dialog only
    pauses the Activity, so the start/stop-driven refresh missed the grant
    until an app restart (Settings card and Home prompt both fixed)
  - Wallet: real logos for SKY/BTC/ETH/USDT (bundled CC0 PNGs, no network);
    user-added Fibercoins and ERC-20 tokens take an image from the photo
    picker, cropped and copied into app storage, stored via CoinSpec.icon
  - Killswitch card: drop the zero content-padding that pushed the
    Always-on VPN settings label out of its button
  - Copy: every "fiber coin" is now Fibercoin
…ts chrome

The trading UI is a vendored single-theme page — dark navy, six custom
properties on :root — so on a phone set to Light it arrived as the one
dark screen in the app. It now follows the app's resolved theme, after the
user's own Light/Dark override, the same way the embedded chat does.
Theme. An injected stylesheet re-points the page's own tokens under a class
this side controls: dark keeps the page's design on the app's ground (one
token, so the document behind the native header is the app's navy), light is
the palette from ui/theme/Theme.kt. Every hard-coded translucent dark in the
page's CSS is white-on-navy arithmetic that turns to mud on white, so each is
re-based on ink or the brand — inputs, the trade legs, address boxes, the
progress track, the shadows tuned for a dark ground. Two exceptions are
called out where they are made: --sky-white is the ink token and the primary
button fills with brand blue, so that button keeps white text on light; and
the trade builder's amount stays transparent so it reads as a figure on its
panel, not a field. color-scheme flips with the rest, so scrollbars and
select popups follow. Committed from onPageCommitVisible, so a light load
never flashes navy, and re-applied live when the theme changes with the page
open.
The platform's JS dialogs go with it. window.confirm is what guards
cancelling a listing, and an Activity theme cannot see a choice that lives in
a composition local — so the half is now named explicitly and a confirm no
longer lands as a dark slab over a light page.
Cards/List, where it means something. The switch was a bar of its own above
every tab, including Settings, which is a form with nothing to switch. It now
appears only where there are rows to read — the test is the DOM's, not a list
of tab names — and rides in the heading that owns them: the market's
"Available products", every other tab's own title. One line, switch on the
right, no separate row of chrome.
Clear history moves to Settings. Beside the list it wipes, it was one mis-tap
from the tab you just opened; it belongs with the other things you set once.
The two tabs are separate React screens, so it is re-offered rather than
moved — same localStorage key, same confirm, same action.
Verified on an emulator against a live market: light and dark across Market,
History and Settings, the confirm dialog, and the clear itself.
… delta

Nine e2e tests failed on the same precondition — "visor visor-a did not
appear in DMSG discovery with delegated servers within 2m0s" — and the
suite then blew its 45-minute budget on the waiting. The visors were
healthy; they were simply not in discovery. visor-a boots at 08:58:42 and
its first "Updating entry." is at 09:03:43, exactly one UpdateInterval
later, with nothing in between and no error logged.
The first-session publish was being skipped silently. updateClientEntry
reads the current entry and writes only on a delta, but a visor's disc
client is a registering fallback whose reads resolve direct-first, and the
visor seeds its OWN pk into that direct client, where GetAllEntries
synthesizes a self-entry listing every configured server as delegated. The
read describes local config, not what dmsg-discovery holds. With a single
dmsg server — every e2e run — that synthetic set equals the live session
set, so the delta was empty on the very first publish and the client stayed
unregistered until the periodic tick. In the log, visor-a's own pk never
once falls through to HTTP discovery while other keys do so 97 times.
updateClientEntry now forces the write when it has never published and has
a session to announce. Steady-state suppression is unchanged: an unchanged
set before the entry is due still writes nothing, which is what keeps
reconnect storms off the discovery. SetDiscoveryClients already clears
lastPushedSrvPKs for the same reason, so nil is the established "never
published here" signal rather than a new one.
The e2e helper's freshness bound moves with it. A flat 60s was calibrated to
clients inheriting the server's DefaultUpdateInterval; against the client's
own five-minute cadence it rejected a good entry four minutes out of five.
It now derives from that cadence, and the restart test — which does need
proof of a re-registration — pins its cutoff to the restart itself instead
of "now − 60s", which had been landing before the restart it was verifying.
The publish stall predates the cadence change and cost a rebooted visor
minutes of being undialable; the five-minute interval only widened it past
what the tests would wait. A test pins the invariant, and fails without the
fix.
@mrpalide
mrpalide merged commit af25786 into skycoin:develop Aug 9, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant