Relaxes the view-center clamp from ±80° to ±89°. Previously opening or
tapping a geohash above 80°N/S clamped the fly-in and syncSelection()
encoded the clamped center, silently returning a different cell than
the user picked. Verified with p2 (87°N) and p6 (85°S) cells: the view
now centers on the requested cell and selects it correctly.
Adds public-domain Natural Earth vector overlays in the established
terminal style, bundled as compact assets (works fully off-grid):
- world_borders.geojson (56KB): admin-0 boundary lines, stroked as
front-facing horizon-clipped runs in a muted theme color
- world_cities.geojson (99KB, 1251 populated places): zoom-tiered by
scale rank - capitals as accent dots, other cities as muted dots;
name labels in Geist Mono fade in from regional zoom levels to keep
the whole-globe view uncluttered
Reworks the geohash picker as a fully offline, native Compose globe:
- Orthographic 3D Earth rendered on a Canvas: starfield, atmosphere,
shaded ocean, graticule and vector continents from bundled public-domain
Natural Earth 110m data (world_land.geojson, 81KB)
- Geohash cells projected onto the sphere with theme-aware dark styling,
Geist Mono labels, level + coverage readout and pulsing crosshair
- Gestures: drag to spin with inertial fling, pinch to zoom (precision
follows zoom), tap to focus, double-tap zoom, cinematic fly-in intro,
haptic tick on cell change
- Removes the WebView/Leaflet/CDN dependency; picker now works off-grid
and matches the app theme in dark and light mode
Rendering notes: horizon-clipped polygon fills (front-run splitting with
limb arcs) and viewport clipping of all path geometry to avoid Skia
precision loss for coordinates beyond 32767px at high zoom.
- Extract shared PeerAvatar (initial circle + lower-right transport badge)
from the conversation row and use it in the mesh peer list and the
geohash/Nostr people list
- Remove the favorite toggle button from the peer list; favorite state is
now a small star badge on the avatar (filled = we favorited, outline =
they favorited us), so favoriting only happens from the private chat
- Show the unread-count badge on peer rows, matching conversation rows
The previous recheck narrowed the race without closing it. Testing
hotspotHold outside lifecycleLock left this interleaving:
1. the recheck passes, hold not yet set
2. holdForHotspot() sets the flag and calls stop(), which takes the
lock, finds nothing published, and returns having stopped nothing
3. this block takes the lock and publishes the service anyway
Aware ends up running while the hotspot believes it owns the radio,
which is the original failure.
The hold is now tested inside the same synchronized block that
publishes, so the check and the publication are one step against
stop(). Ordering holds because holdForHotspot() sets the flag before
calling stop(): either the publisher observes the flag and abandons,
or stop() observes the published service and tears it down. There is
no interleaving that leaves a service published with the hold set.
stopServices() stays outside the lock since it can block.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses three issues from Codex review on #808.
The Wi-Fi Aware hold could be defeated by a race. startIfPossible()
does a long stretch of async work between checking the hold and
assigning the service, so holdForHotspot() landing in that window
left an in-flight start free to resurrect NAN behind the hotspot's
back, putting every P2P attempt back on BUSY. The hold is now
rechecked before committing the service, and the freshly started
service is torn down if the hotspot claimed the radio meanwhile.
Startup error paths bypassed cleanup. A web-server failure, a null
connection info, or a throw from the outer block stopped the manager
but left the Aware hold set, blocking all mesh starts until the user
happened to retry or close the screen. Worse, an error after the
server had started left it serving the APK on port 9999 -- including
after the device reconnected to an ordinary Wi-Fi network -- because
only stopHotspot() cleared it.
Both follow from the same gap: cleanup lived at the call sites rather
than in one place. All failures now go through failWith(), which
shares teardown() with stopHotspot() and releases the server, the
manager and the hold together.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Starting the APK-sharing hotspot failed intermittently with
"Failed to create hotspot: BUSY", sometimes for minutes, then
succeeded for no apparent reason. Two distinct causes, both
confirmed against a Pixel 9a via dumpsys and HAL logs.
1. Wi-Fi Aware holds the radio. The mesh's NAN interface and
Wi-Fi Direct's P2P interface cannot coexist on common chipsets:
HalDevMgr: bestIfaceCreationProposal is null, requestIface=P2P,
existingIface=[name=wlan0 type=STA, name=aware_nmi0 type=NAN]
WifiP2pNative: Failed to create P2p iface
The P2P state machine then stays in P2pDisabledState and answers
every createGroup with BUSY, while still broadcasting
WIFI_P2P_STATE_ENABLED. Whether sharing worked came down to
whether Aware happened to be attached, which is what made it look
random. WifiAwareController now releases Aware for the duration of
the hotspot and blocks restarts until it finishes.
2. Orphaned groups. A P2P group outlives the process that created
it, so a crash or swipe-away while hosting leaves one behind, and
the framework answers BUSY for as long as it exists. Startup now
removes a stale group first, but only one it can show is ours --
Wi-Fi Direct is shared with Cast, Android Auto and Quick Share.
Ownership is the group name we recorded creating, with the SSID
prefix as a fallback for orphans from older builds.
Also fixed while tracing these:
- Channel leak: initialize() ran on every retry attempt and the
channel was never closed, leaving a binder registration with
WifiP2pService per attempt. Observed climbing to 7 stale clients.
It is now initialised once and closed after removeGroup replies.
- BUSY is the framework's catch-all reply, so retrying was futile
for permanent causes and too impatient for real contention.
Retries now back off 1s/2s/4s/8s and only for genuinely transient
failures; P2P being off fails immediately with a message that says
so rather than 15 seconds ending in "busy".
- Turning Wi-Fi off mid-session left the UI showing an active
hotspot forever; it now aborts cleanly.
Retry and startup decisions are extracted into HotspotStartupPolicy,
which has no Android dependencies and is covered by 13 unit tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hebrew, Polish, Simplified Chinese, Traditional Chinese, Malay, Tamil,
and Ukrainian were only 10-12% translated (38-48 of 395 string keys),
falling back to English for nearly everything. Backfill each to 100%
(395/395 keys), including the two shared plurals (notification_and_more,
people_count) with locale-correct CLDR plural categories.
Also:
- Fix values-zh-rTW: ~27 keys in the verify_*/fingerprint_* block were
Simplified Chinese pasted into the Traditional Chinese file (e.g. 验证
instead of 驗證); corrected to proper Traditional Chinese script.
- Fix a leftover English verify_*/fingerprint_* block (~30 keys) present
in pl, ms, ta, uk that predated this change and wasn't part of the
originally-missing-key set.
- Fix values-pl version_prefix, mistranslated as "w%1$s" instead of
preserving the literal version-string prefix "v%1$s".
- Fix a duplicate-key bug in values-ms where a stale untranslated
<string name="notification_and_more"> coexisted with the new
<plurals name="notification_and_more">.
All translations are machine-translated (flagged as such via an
in-file comment: "pending native-speaker review") and should be
reviewed by fluent speakers before being considered final. Verified:
well-formed XML, exactly 395/395 keys with no duplicates in all 7
files, zero placeholder (%1$s/%2$d/etc.) mismatches against the
English source, and a clean `./gradlew :app:processDebugResources`
resource compile.
Relates to #737 (multilingual support request) — this addresses the
"languages already added but barely translated" half of that issue;
an in-app language switcher / android:localeConfig and a proper
community-translation pipeline (e.g. Weblate) are separate follow-ups.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>