removeGroup() is device-scoped: it removes whatever Wi-Fi Direct group
exists, including one owned by Cast, Android Auto or Quick Share.
stopHotspot() called it unconditionally, so the path built to protect a
foreign group tore that group down anyway. Removal on stop is now gated on
a createdGroup flag, set once our own createGroup command is accepted; with
nothing of ours on the framework, stop closes the channel and leaves the
group alone.
When a group we did not record creating is active at start, the app no
longer guesses about ownership - it asks. A confirmation dialog explains
that starting will disconnect the current Wi-Fi Direct connection;
confirming retries the start with replacement authorized, cancelling
leaves everything untouched. Consent is bound to the group it was given
for: the conflicting group's name travels through the dialog, and the
policy only authorizes removing a group with exactly that name - one that
appeared later, or swapped in mid-retry, re-prompts instead of riding on
stale approval.
Because consent replaces ownership proof, the DIRECT-BC- prefix heuristic
is gone: a prefix match is not ownership (this device can be connected to
another phone's bitchat group), so only the exact recorded name counts.
The record is also kept honest: never taken from a group we do not host,
and never overwritten while an old group of ours may still exist, so a
BUSY retry cannot misclassify our own stale group as foreign.
Also: SecurityException guards on the removeGroup() sites reached from
framework callbacks (permission revoked mid-session crashed instead of
failing cleanly); stopHotspot() takes a completion callback so the
ViewModel releases its Wi-Fi Aware lease only after the framework
acknowledges the removal, with an idempotent 10s fallback so a dropped
acknowledgement cannot pin the mesh down; and the confirm/cancel handlers
guard on the ConfirmDisconnect state so a tap landing through a screen
transition cannot tear down a just-confirmed session.
Replaces the state-machine approach of #811 - same protection at
proportionate cost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`BitchatFilePacket.decode` resolved every tag through the four-value
`TLVType` enum and bailed on the first miss:
val t = TLVType.from(data[off].toUByte()) ?: return null
So a file packet carrying one tag this build does not know is not
partially understood — it is discarded whole, media included. The
receiver shows nothing and logs nothing; the sender sees a successful
transfer. Both apps look healthy.
iOS has always skipped unknown tags (`case nil: continue` in its own
`BitchatFilePacket.decode`), so the two implementations disagreed about
what a valid packet is, and the tag list stopped being extensible in
practice: any optional field added by a newer or third-party client
costs every Android peer the whole file rather than just that field.
Unknown tags now advance past the value, exactly as iOS does. Known-tag
handling, the 4-byte CONTENT length, multi-CONTENT concatenation and
every existing rejection are unchanged.
The skip path is deliberately allocation- and log-free per TLV, because
its iteration count is chosen by the sender: a zero-length unknown TLV
costs 3 bytes, so a padded packet would otherwise mean millions of empty
array copies and formatted log lines monopolising the mesh handler. The
count is reported once after the loop instead.
Tests: `decode should skip unknown TLV types instead of dropping the
whole file` (extension before CONTENT), `decode should skip an unknown
TLV that trails the content` (after it), and `decode should handle a
packet padded with many zero-length unknown TLVs` (200k of them). The
first two fail on the old decoder.
Two fixes to how Wi-Fi Aware yields the radio to the Wi-Fi Direct hotspot.
A restart request could be swallowed: restartIfStillEnabled() coalesces on
an in-flight flag, so a request arriving while an earlier loop was still
burning attempts against the hotspot hold lost the CAS and was dropped.
The loop then exhausted its attempts without ever seeing the cleared hold,
leaving the mesh down despite the user's setting. Requests are now recorded
before coalescing and re-checked after each pass.
The hold itself was a single flag, so overlapping share sessions could
release each other's claim. It is now a counted, once-releasable
HotspotLease: Aware restarts only when no session still needs the radio,
and a duplicate or late release is a no-op. Publication of a started
service is checked against the hold inside the same lock stop() takes, so
a start racing a new hold cannot resurrect NAN while the hotspot owns the
radio.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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
A bare `test -n` does not abort in a shell without set -e, so a failed
mktemp would let `--out ""` resolve to the repository root and write
private evidence there. Guard with an explicit exit instead.