Compare commits

...

619 Commits
0.8 ... main

Author SHA1 Message Date
callebtc
09b481f1ef
Merge pull request #876 from qutad/feat/announce-capability-bits
feat: Add announce capability bit assignments
2026-08-17 21:05:02 +02:00
callebtc
db97c60af1 Merge remote-tracking branch 'origin/main' into feat/announce-capability-bits 2026-08-17 20:40:43 +02:00
callebtc
2318b51630
Merge pull request #897 from permissionlesstech/codex/fix-command-processor-test-brace
Fix CommandProcessor test syntax
2026-08-17 20:38:39 +02:00
callebtc
03d1da5423 Fix CommandProcessor test syntax 2026-08-17 20:13:19 +02:00
callebtc
646044cd81 Clarify capability authentication scope 2026-08-17 19:14:05 +02:00
callebtc
35ed4417f0
Merge pull request #841 from phuctoan123/codex-fix-geohash-join-channel-routing
Fix geohash join channel routing
2026-08-17 19:09:26 +02:00
callebtc
9167013ac4
Merge pull request #863 from areebahmeddd/fix/preserve-wire-payload
feat: preserve original bytes during re-encoding of packets with foreign encoders
2026-08-17 19:08:01 +02:00
GitHub Action
b028270cc4 Automated update of relay data - Sun Aug 16 06:08:32 UTC 2026 2026-08-16 06:08:32 +00:00
wollow
22f87a493a Add announce capability bit assignments 2026-08-13 15:11:54 +03:00
callebtc
5156f7de89 Restore PRIVACY_POLICY.md 2026-08-12 01:55:05 +02:00
callebtc
93e9594bad
Merge pull request #873 from permissionlesstech/codex/bump-version-2.0.1
Prepare Android 2.0.1 release
2026-08-11 21:09:03 +02:00
callebtc
16a22316b2 Disable nondeterministic Compose group mapping 2026-08-11 20:42:26 +02:00
callebtc
c549bdb03f Bump Android release to 2.0.1 2026-08-11 20:08:57 +02:00
callebtc
c9938a36ea
Merge pull request #872 from permissionlesstech/codex/fix-reproducible-aab-mapping
Fix reproducible R8 mapping output
2026-08-11 19:57:31 +02:00
callebtc
6c5d7d1ef0 Fix reproducible R8 mapping output 2026-08-11 19:33:44 +02:00
callebtc
1a0d8713e4
Merge pull request #871 from permissionlesstech/watch-release-0.1.0
Prepare Wear 0.1.0 reproducible release
2026-08-11 18:14:29 +02:00
callebtc
c9a4e68157 Serialize reproducible release modules 2026-08-11 15:38:15 +02:00
callebtc
7e8ea63230 Prepare reproducible Wear 0.1.0 release 2026-08-11 15:19:39 +02:00
callebtc
47e725a4d8
Merge pull request #870 from permissionlesstech/bump-version-2.0.0
Bump phone app to 2.0.0
2026-08-11 15:12:58 +02:00
callebtc
920eed52d7 Bump phone app to 2.0.0 2026-08-11 12:13:16 +02:00
callebtc
fcb4562bd5
Merge pull request #812 from moehamade/fix/apk-download-and-rate-limit
fix: make APK sharing local-first and rate-limit safe
2026-08-11 09:51:39 +02:00
Kane Waldo
4007bae1e2 Fix syntax error in CommandProcessorTest 2026-08-11 09:20:39 +07:00
Moe Hamade
8f21ad5be3 fix(apk): keep the local APK shareable across a restart mid-download
Codex is right about this one. The downloader observer builds Downloading
out of whatever status it replaces, reading shareableFallback from an
existing Downloading or a current Ready. A ViewModel restored onto work
that is already active starts from Loading, so neither cast matches and
the fallback is null. WorkManager keeps a download running across process
death, so this is the ordinary case: background the app during a 42 MB
transfer over Tor, come back, and the row drops to "Prepare App for
Sharing" while Share via Hotspot and Share via Quick Share disappear
entirely. The installed APK never moved - it was on disk and shareable a
moment earlier - and it stays hidden until the download ends.

checkStatus() could not repair it because its guard conflated two things:
not letting a resolved status overwrite active work, which is right, and
not looking at local state at all during a download, which is not. Both
orderings lost. If the observer arrived first the guard returned early. If
checkStatus() arrived first it suspended on disk IO, the observer flipped
the state underneath it, and the re-check discarded the status it had just
resolved.

Resolves the local artifact either way and decides inside the same state
update, where the active download is visible: the download keeps the
status it owns, and adopts the artifact only when it is carrying none.
Metadata is still skipped while work is active, so this costs no extra API
budget.

Verified on a Pixel 9a by starting a download, force-stopping mid-transfer
and reopening: the row holds "App Ready for Offline Sharing" with both
sharing rows present, where it previously showed neither.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:49:22 +03:00
Moe Hamade
52f14cc5b5 fix(apk): stop a reset header alone from marking a 403 rate limited
GitHub sends X-RateLimit-Reset on every REST response, an ordinary 403
included, and it always points at the current window. Feeding it through
retryAtMillis() therefore produced a non-null deadline for any 403, and
the classifier accepted that as proof of a limit. A permissions failure
with the quota untouched came back as RateLimited, so the caller persisted
a cooldown on that route and served stale metadata until a reset window
the failure had nothing to do with.

Classification now looks only at signals that actually mean this request
was the one refused: a spent quota, or an explicit Retry-After. Nothing
real is lost, because GitHub marks a primary limit with
X-RateLimit-Remaining: 0 and a secondary limit with Retry-After. The reset
header keeps its job of supplying the deadline once a limit is
established some other way.

ApkDownloadSourceTest already claimed this contract - its name is "403 is
only treated as a limit when response headers say so" - but its
permissions case passed no reset header at all, which is the one input
that hides the bug. Adds the case it was missing, which fails without this
change, and pins the secondary-limit path so tightening the reset header
cannot blind the client to a Retry-After.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:16:51 +03:00
Moe Hamade
7b86bafbac fix(apk): give the persisted store sole ownership of rate-limit cooldowns
The cooldown was tracked in two places. ApkRateLimitStore persisted it per
scope and per route, and the ViewModel kept a second copy in
downloadRetryAtMillis plus a retryAtMillis on Resumable and Error, frozen
into WorkManager output data on the way through. The copy was the weaker
of the two: it lived in memory, so a cold start lost it, and the deadline
it froze belonged to whichever route earned it, which is exactly the drift
the per-route store exists to prevent.

It also could not expire on its own. downloadRetryBlocked was computed
with System.currentTimeMillis() during composition, so nothing recomposed
when the deadline passed; scheduleRetryUnlock papered over that with a
viewModelScope delay that died with the process. Meanwhile the disabled
row and icon gave the user no countdown to read, so a tap simply did
nothing.

Drops the copy. The store is consulted where the request is actually made
and the UI stays enabled, which costs a worker that fails in well under a
tenth of a second without touching the network.

RateLimitedWithWait goes with it. Its "try again in %2$s min" was computed
at failure time and baked into static text that never ticked down, so it
was wrong within a minute; RateLimited says "try again later" and stays
true. Removing it leaves nothing pre-formatted, so Resumable and Error now
carry an ApkFailureMessage of string id plus arguments and the row
resolves it during composition. Failure text follows the device locale
rather than the locale the worker happened to run under.

Anchors the GitHub cooldown at the moment it is judged. now was sampled
before awaitRoute(), which can hold a request for the full 60-second route
timeout, so a relative Retry-After interpreted against it could land in
the past and let the very next check reach GitHub - the loop this branch
set out to close. Reads the clock again once the route is ready and once
the response arrives, and uses each where it applies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 14:57:32 +03:00
Moe Hamade
000a8acdb9 fix: build each shared HTTP client once instead of racing to replace it
routedHttpClient() and webSocketClient() both did a plain check-then-set
on their AtomicReference: read, and if empty build a client and store it.
Two threads arriving together each saw an empty reference, each built a
full OkHttpClient, and the loser's client was dropped on the floor with
its connection pool and dispatcher threads already allocated. Nothing
closed it, so the leak lasted until the process died.

Moves construction inside a lock and re-checks the reference there, so
the second thread returns the first thread's client rather than building
its own. reset() takes the same lock, which is what makes the pairing
airtight: a build can no longer interleave with a reset and store a
client for the route that was just discarded. The fast path stays outside
the lock, so a warm client still costs a single volatile read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 14:53:42 +03:00
GitHub Action
55fd6ad9bd Automated update of relay data - Sun Aug 9 06:17:04 UTC 2026 2026-08-09 06:17:04 +00:00
Toan Wizard
ca992d169c
Merge branch 'main' into codex-fix-geohash-join-channel-routing 2026-08-04 09:30:27 +07:00
Moe Hamade
99bed510de build: drop verification entries for the reverted material3 alpha
The alpha bump was reverted in 2c19d00d, but its verification metadata
stayed behind: 45 components for compose 1.12.0-beta01 and material3
1.5.0-alpha25 that no lockfile resolves. Regenerated from upstream's file
so only artifacts this branch actually pulls are trusted.

Trusting artifacts nothing resolves is the opposite of what this file is
for, and it made the branch look like a Compose beta upgrade in review.

gradle/verification-metadata.xml: +322 lines -> +8, one component
(mockwebserver3, the dependency 2c19d00d genuinely added).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 20:12:50 +03:00
Moe Hamade
a898c06114 build: relock :wear after mockwebserver entered the shared test bundle
CI failed at ':wear:compileDebugUnitTestKotlin' with okhttp, okio and
mockwebserver3 "not part of the dependency lock state". No test ran.

Adding okhttp-mockwebserver to the shared test bundle put okhttp and okio
on :wear's unit-test classpath as well, but only :app's lock state was
regenerated, so :wear/gradle.lockfile had no entry for any of them.

Regenerated lock state and verification metadata for debug and both
release variants per docs/reproducible-builds.md. No new components
needed trusting: the checksums already existed from the :app side, so
this is lockfile scope only.

Worth noting the local command that missed it was :app-scoped;
CI runs testDebugUnitTest at the root, which includes :wear.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:08:52 +03:00
callebtc
094657efa0
Merge pull request #861 from permissionlesstech/codex/delay-peer-availability-notification
Delay nearby peer notification to collect accurate count
2026-08-03 00:18:52 +02:00
callebtc
1376b55931 Delay nearby peer availability notification 2026-08-02 21:54:03 +02:00
callebtc
d852f3dc3a
Merge pull request #744 from aharshit123456/i18n/backfill-incomplete-locales
i18n: backfill 7 severely incomplete locale translations (he, pl, zh-rCN, zh-rTW, ms, ta, uk)
2026-08-02 21:34:49 +02:00
callebtc
94e46da234 Merge main into i18n backfill branch 2026-08-02 20:36:48 +02:00
callebtc
49753ccb88
Merge pull request #860 from permissionlesstech/feat/bubble-media-messages
feat(ui): wrap image and voice messages in bubble shells
2026-08-02 20:33:10 +02:00
callebtc
bc572cc2ea fix(ui): make media bubble shell long-clickable
Self and grouped voice-note bubbles had no long-press target: the sender
label is hidden and VoiceNotePlayer controls consume touches, so the
message action sheet was unreachable. combinedClickable on the shell
restores long-press for every media bubble.
2026-08-02 20:28:04 +02:00
Moe Hamade
2c19d00d17 fix(apk): preserve local sharing during update checks 2026-08-02 17:44:20 +03:00
Moe Hamade
b60b5121aa fix: observe cancellation before promoting a verified APK
Codex is right about this one. Cancellation in Kotlin is cooperative, and
everything from validateDownloadedApk() through saveMetadata() is plain
blocking code with no suspension point. Stopping during the signature
check was therefore not observed until after the temp file had been
renamed and its metadata written, so the worker committed the APK while
WorkManager reported the work cancelled.

That also raced onCancelDownload(): its checkStatus() could read the cache
before the commit and settle on NotDownloaded, after which the cancelled
work maps to Idle and the observer ignores it. The row then advertised
"Not ready" with a verified universal APK already in the cache, and
tapping it downloaded the same bytes again.

One checkpoint after validation, which is the slow step and so the most
likely moment to press Stop. The verified temp file is left in place, so
the next attempt resumes rather than starting over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:58:18 +03:00
Moe Hamade
408d1c760a fix: name the retry backoff instead of calling it a network wait
Codex caught this and it is correct. WorkManager returns a retried
request to ENQUEUED for the duration of its backoff whether or not the
device is online, and the mapping sent every ENQUEUED record to
AwaitingConnectivity. With exponential backoff from 15s over three
attempts, a fully connected device claimed "Waiting for network…" in both
the row and the notification for roughly 45 seconds.

ENQUEUED covers two different waits and the state alone cannot separate
them; a non-zero runAttemptCount means the work already ran, so it is the
backoff. Adds a Retrying phase for that case, extracted as queuedPhase()
so the distinction is testable without a WorkInfo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:58:18 +03:00
Moe Hamade
700e9aa0e5 fix: carry download failures by stable name, not resource id
Codex flagged this on the string-extraction change and it is right.
WorkManager keeps failed records in its own database across app updates,
and AAPT2 reassigns R.string ids on every build. A failure written by one
build and read by the next would resolve its persisted int against a
different resource table: wrong string, or NotFoundException, or
IllegalFormatException when the placeholder arity no longer matches. The
existing zero-check only caught an absent key, not a stale valid one.

ApkDownloadFailureReason now names each failure and owns its string, and
the boundary carries the enum name. This is the same treatment
DownloadPhase.fromKey already gives the phase across the same boundary,
including tolerating a name this build no longer has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:58:18 +03:00
Moe Hamade
bfb0c82ef9 feat: rebuild the prepare-for-sharing row and localize its failures
Progress moves out of the trailing slot and under the subtitle, so every
status now shows exactly one 48dp control there instead of a spinner and
a button competing for the same space. The trailing slot had three
different widths across states, which made the text column re-wrap on
every status change; it is now one width throughout.

"Get universal" and "Retry" become icon buttons. That removes the labels
they were leaning on, so both gain tooltips and real content
descriptions, and prepareRowTapAction() now drives the row's enabled flag
and its tap handler from one mapping. Previously the row rendered as
clickable in the ready state but onPrepareRowClicked ignored it, leaving
the icon as the only way to reach the universal download.

Resumable downloads get a progress bar for the first time, drawn flat via
amplitude 0 so a stalled download does not look like a live one.

Strings: every user-facing literal now lives in strings.xml. Download
failures were assembled as English sentences in the util layer, which has
no Context by design, so they crossed the WorkManager boundary already
formatted and could never be translated. ApkDownloadException now carries
a string resource and its arguments, and the ViewModel resolves them
against the device locale. util/ stays Context-free and its tests stay
plain JUnit.

Also drops translatable="false" from seven strings that were visible
prose, and stops showing raw exception text when the APK status cannot be
read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:58:18 +03:00
Moe Hamade
7dab62733a build: take material3 1.5.0-alpha for the wavy progress indicators
The expressive wavy progress indicators are Compose-only in the 1.5.0
line, which has no stable release yet, so this overrides the BOM's 1.4.0
for that one artifact.

The override sits outside the BOM, so material3's own requirements win
and pull ui, runtime, foundation and animation from 1.11.4 to
1.12.0-beta01. That is the real cost of this change and the reason the
lock diff is 46 components rather than one.

Lock state and verification metadata regenerated for debug and both
release variants per docs/reproducible-builds.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:56:25 +03:00
Moe Hamade
821ec7c5f7 fix: check the cooldown on every attempt, not once before the loop
Addresses review on #812.

Each retry resampled the route but never rechecked the gate against it,
so a route change during a request or its backoff walked straight past a
cooldown. A Tor attempt failing with a 500, then the user switching to a
direct connection that is already rate limited, and the next attempt
contacts it regardless.

The check moves inside the loop, immediately after the route is sampled,
which makes it cover the first attempt too -- the separate post-wait
check it replaces was only ever that first iteration. The check before
the route wait stays: knowing the selected route is blocked is worth
avoiding a sixty-second Tor bootstrap for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:56:25 +03:00
Moe Hamade
309df1ab61 fix: evaluate the rate-limit gate against the route each request takes
Addresses review on #812. Both findings are the same mistake: the route
was sampled at a moment that need not match the request it governs.

The gate was checked once, before a route wait that can last a minute.
Start with Tor selected, disable it during the wait, and the request
goes direct having consulted only the Tor deadline -- contacting a
direct IP whose own cooldown is still running. The gate is now
re-evaluated after the wait, when the route the request will take is
finally known.

The cooldown was likewise recorded against the mode selected when the
response arrived, not the one the call was made on. Changing the setting
mid-flight filed it in the wrong bucket, freeing the limited route and
suppressing the newly selected one. The route is now sampled
immediately before each attempt and reused for that attempt's response
and its retry decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:56:25 +03:00
Moe Hamade
2d2954709d fix: keep a cooldown per route instead of discarding it on a switch
Addresses review on #812.

Scoping the gate to the route was right, but it was implemented as one
deadline that moved with the route, so a switch deleted the cooldown
rather than setting it aside. Rate-limited on a Tor exit, switch to
direct, switch back before the reset, and the app contacts that same
limited exit again with nothing left to stop it.

Tor and direct now carry their own deadlines. Switching route selects
the other one rather than forgetting this one, a success clears only the
route that succeeded, and when the route cannot be determined the
stricter of the two applies -- failing to identify a route must not
release a cooldown that is still running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:56:25 +03:00
Moe Hamade
4200871ee9 fix: key the rate-limit gate to the selected route, and drop the force flag
Addresses review on #812.

isProxyEnabled() reports readiness, not route selection: it is false
while Tor is bootstrapping or restarting, even though requests will
still go through Tor. Using it as the route identity cleared a
Tor-earned gate mid-bootstrap and applied a direct-earned one to the
first Tor request -- the opposite of what scoping the gate was for. The
identity is now the selected mode from statusFlow.

The force flag turned out to be both unnecessary and harmful. Leaving
Downloading synchronously before the check already clears the entry
guard, so force only reached the completion guard -- which must stay
armed. A cancellation check can take a minute on the route timeout, and
WorkManager can surface Resumable meanwhile, so the user may start a new
download before it returns; force let the stale result overwrite work
that was running and strip the progress and stop controls. Removing it
restores that protection and needs no generation counter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:56:25 +03:00
Moe Hamade
77f3c0cc05 fix: free the UI on cancel, and scope the rate-limit gate to its route
Addresses review on #812.

Cancelling left the spinner up for as long as the status check took.
Forcing checkStatus() past its guard was not enough: resolveApkStatus()
calls checkForUpdate(), which reaches the network and can sit on the
60-second route timeout while Tor bootstraps. Nothing clears the state
in the meantime -- the cancelled job maps to Idle, which the observer
ignores -- so the stop button looked broken for the whole wait. The
state now leaves Downloading immediately and the check resolves it
afterwards.

The rate-limit gate was process-wide. GitHub counts unauthenticated
requests per IP, so a cooldown earned through a shared Tor exit was
being applied to a direct connection with an entirely different quota,
and vice versa -- potentially suppressing a usable route for an hour.
The gate now records which route earned it and is dropped when the
current route differs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:56:25 +03:00
Moe Hamade
8e5bb2ea9a fix: clear the downloading state when a download is cancelled
Addresses review on #812.

Pressing the new stop button before a partial file exists -- while
resolving the release, or waiting for Tor -- left the row disabled and
spinning for the lifetime of the ViewModel. Two guards conspired:
checkStatus() returns early while the state is Downloading, and the
downloader observer deliberately ignores the Idle that WorkManager
reports for a cancelled job. Both exist to stop a running job being
second-guessed from cache contents, and neither anticipated a job that
is no longer running.

checkStatus() takes a force flag, used only by cancellation, and clears
the stale progress along with the status.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:56:25 +03:00
Moe Hamade
b79ae8e7f1 fix: stop reporting a Tor wait during the fetch, and serve the cache on
the rate limit that triggers the gate

Addresses review on #812.

The awaiting-route phase was never cleared. Reporting it before the wait
fixed the label on the wait itself, but nothing restored ResolvingRelease
afterwards, so the UI and notification claimed "Waiting for Tor" for the
whole metadata request and its retries -- and in direct mode, where the
wait returns immediately, for a wait that never happened. An
onResolvingRelease callback now fires once the route is ready.

A rate-limit rejection recorded the gate and returned the failure, while
the stale-cache fallback sat at the top of the function and was only
reached on a later call. The first About check therefore reported an
error the user could not act on, and an immediate retry succeeded from
metadata that was already present. The response that sets the gate now
serves the cached release straight away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:56:25 +03:00
Moe Hamade
f33ce0bb98 fix: gate secondary rate limits and name the Tor wait correctly
Addresses review on #812.

Secondary rate limits were classed as permissions failures. GitHub
serves them as 403 with Retry-After while X-RateLimit-Remaining is
still nonzero, because the primary hourly quota is not what was hit --
so isRateLimited() returned false, blockedUntilMillis was never set, no
stale release was served, and every About sheet open kept contacting
GitHub through exactly the cooldown it had been asked to observe. The
predicate now also admits a 403 carrying a usable Retry-After; one that
cannot be parsed is still a permissions failure.

The phase reported during the Tor wait was the wrong one.
fetchLatestRelease() waits on the selected route itself, so the UI read
"Checking latest release..." for the whole bootstrap and only switched
to AwaitingNetworkRoute afterwards, when the second route check returns
immediately -- putting the wrong label on the one wait the phase exists
to explain. Reordering the two calls would have made cache hits wait on
Tor, since the cache returns before the route check, so the fetch now
reports from the inside via onAwaitingNetworkRoute and the caller keeps
its own check for the cache-hit path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:56:25 +03:00
Moe Hamade
389fbd28fe feat: show what an APK download is actually doing
Preparing an APK is a five-stage operation rendered as a single 0-100
bar. Two of those stages run before the first byte -- a GitHub release
lookup, then awaitSelectedNetworkRoute, which blocks on Tor bootstrap --
and neither reported anything, so a download sat at 0% with no
explanation for as long as Tor took. The tail had the mirror problem: a
SHA-256 pass and a signature check over ~100MB, both sitting at 100%.

Carry a DownloadPhase on DownloadState.Downloading, reported from
UniversalApkManager through the worker's existing setProgressAsync and
mapWorkInfoToState. The About sheet names the phase instead of showing a
misleading percentage, and the spinner is indeterminate except while
bytes are actually moving. The notification does the same, and a phase
change forces a redraw so the every-5% threshold cannot suppress it.

The phase crosses a WorkManager Data boundary as a string, so fromKey
falls back to Transferring for an absent or unrecognised value -- work
enqueued by an older build must not crash a newer one.

A stop button is wired to the cancelDownload() that already existed on
the downloader interface but had no UI affordance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	app/src/main/java/com/bitchat/android/ui/AboutSheet.kt
2026-08-02 15:56:25 +03:00
Moe Hamade
657fee0de6 fix: stop the GitHub release check exhausting its own rate limit
Opening the About sheet runs a release check, and an exhausted quota fed
itself: only successes were cached, and a 403 reporting zero remaining
was classed retryable, so every sheet open spent three more requests
rediscovering the same limit. Unauthenticated GitHub allows 60 requests
an hour per IP, and over Tor that IP is an exit node shared with every
other user on it, so the ceiling arrives far sooner than per-user maths
suggests.

Three changes:

- Conditional requests. The client now stores the release ETag and
  replays it as If-None-Match. GitHub does not charge a 304 against the
  rate limit, so revalidating an expired cache is free where an
  unconditional refetch costs one of the 60. This is why neither polling
  nor long polling is the right answer here.

- A rate-limit gate. X-RateLimit-Reset and Retry-After were read only to
  interpolate into an error string; they now set a deadline before which
  no request is sent at all. While blocked, a stale cached release is
  served in preference to an error the user cannot act on. Clamped to an
  hour so a bad header cannot lock the feature out, and a reset time in
  the past falls back to a fixed backoff rather than unblocking a skewed
  clock immediately.

- Rate limits are no longer retried in-loop. The gate decides when it is
  worth asking again. A plain 403 is a permissions failure and is no
  longer retried either.

The gate's decision logic is pure and unit tested. The wiring around it
is not: that needs a MockWebServer, which is not currently a dependency.

Known gap: the cache and ETag are in memory only, so a process restart
still costs one request. Persisting them needs a Context threaded into
what is currently a context-free object; left as a follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 15:56:25 +03:00
GitHub Action
c02dda308e Automated update of relay data - Sun Aug 2 06:55:18 UTC 2026 2026-08-02 06:55:19 +00:00
callebtc
a49f38216a
Merge pull request #853 from permissionlesstech/codex/remove-rssi-polling
Remove periodic GATT RSSI polling
2026-08-02 03:12:40 +02:00
callebtc
2325b7c9c3 Remove periodic GATT RSSI polling 2026-08-02 01:42:45 +02:00
callebtc
b8c7470ace
Merge pull request #847 from heyaim/hide-command-suggestions-after-send
Hide the command and mention popups after a message is sent
2026-08-02 00:17:28 +02:00
callebtc
28bd6bdcfd
Merge pull request #852 from permissionlesstech/codex/fix-readme-screenshot-crop-20260801
Fix cropped README screenshot controls
2026-08-02 00:11:06 +02:00
callebtc
dcec0c10ec Test README screenshot crop handling 2026-08-02 00:06:36 +02:00
callebtc
17a173a685 Fix cropped README screenshot controls 2026-08-02 00:03:46 +02:00
callebtc
b43dc1e48f feat(ui): wrap image and voice messages in bubble shells
Bubble mode previously rendered media as flat header-plus-card rows while
text messages got the tinted bubble treatment. Introduce MediaBubbleShell,
which mirrors BubbleTextMessageLayout (author-colour wash and hairline,
speaker-side tail, in-bubble sender name, flush-right timestamp with
delivery ticks for own private messages), and route ImageMessageItem and
AudioMessageItem through it. Matrix mode is unchanged.
2026-08-01 23:48:13 +02:00
callebtc
53cd03886c
Merge pull request #851 from permissionlesstech/codex/refresh-readme-screenshots-20260801
Refresh README feature screenshots
2026-08-01 23:40:19 +02:00
callebtc
5cc181f1e2 Improve README screenshot capture recipe 2026-08-01 23:38:26 +02:00
callebtc
1965d4bd7e Refresh README feature screenshots 2026-08-01 23:25:03 +02:00
callebtc
2e943b2ebc
Merge pull request #846 from permissionlesstech/bitchat-ui-bubbles
feat(chat): add bubbles chat UI mode with peer-color bubbles
2026-08-01 21:31:57 +02:00
callebtc
aad0c4d7f8 feat(settings): combine theme and chat style into one card
System/Light/Dark and the chat style picker now share the single Theme
card in About -> Settings: theme chips on the first row, chat style
chips on the second, ordered Bubbles then Matrix. Bubbles remains the
default for fresh installs.
2026-08-01 21:17:30 +02:00
callebtc
bd5ae8702a feat(chat): never show our own nickname in bubbles mode
The end side and the author-colour wash already attribute own bubbles,
so the @name heading on the first bubble of an own run was redundant.
Received bubbles keep their sender names; matrix mode is unchanged.
2026-08-01 20:45:42 +02:00
callebtc
d85401fb49 fix(chat): never let the bubble meta cluster influence text wrapping
The no-break-space reservation narrowed the text's own wrap width, so
first lines wrapped early and carried dead slack. The cluster (timestamp
plus delivery checks) is now placed from the laid-out text: it rides
flush-right in the last line's slack when there is room and drops below
the text only when there is not. The body wraps at the full bubble
width either way, one-liners keep hugging their content, and matrix
mode is unchanged. AnnotatedClickableText gains an optional onTextLayout
callback to support the measurement.
2026-08-01 20:10:25 +02:00
callebtc
79d2e0328a fix(chat): keep bubble meta flush-right without extra lines or width
The timestamp + checks cluster now overlays the bubble's bottom-end
corner while the body reserves an invisible no-break-space run (capped
by a zero-width word joiner so it is never trimmed) exactly where the
cluster lands. The reservation rides the last text line when there is
room and wraps only when there isn't, so the cluster sits flush-right
on the last line for every message length: no overlap, no forced new
line, no minimum bubble width. Matrix mode is unchanged.
2026-08-01 19:55:07 +02:00
callebtc
6d9dfd970c fix(chat): trail bubble timestamp and checks inline with the body
Replaces the separate bottom meta row, which forced every bubble to at
least the meta row's width and added a line even to one-line messages.
The timestamp + checks cluster now trails the body inline: it rides
the last text line when there is room and wraps only when there isn't,
so bubbles hug their content again. Own private messages keep the
constant-width grey-to-green checks with the colour tween; matrix mode
is unchanged.
2026-08-01 18:04:39 +02:00
callebtc
d0c2a39140
Merge pull request #840 from permissionlesstech/codex/wear-speech-bubble-app-icon
Replace Wear app icon with green BitChat glyph
2026-08-01 18:03:16 +02:00
callebtc
498c35188f feat(chat): right-align bubble timestamps beside the delivery checks
Bubbles now park a bottom meta row at their end edge, like classic
messengers: the timestamp sits right-aligned, followed at a fixed gap
by the delivery checks for own private messages, instead of trailing
the body text mid-line. Matrix mode keeps its inline trailing
timestamps.
2026-08-01 17:34:36 +02:00
callebtc
6eafa5932d feat(chat): anchor delivery checks to the bubble's bottom-end corner
Instead of trailing the timestamp mid-line, the delivery checks now
park at the bubble's bottom-end corner like classic messengers, with
the body text reserving a small end inset so the last line never
collides with them. The checks keep their constant-width grey-to-green
behaviour, colour tween, and scale pop (shared DeliveryStatusIcon).
Matrix mode is unchanged.
2026-08-01 17:07:41 +02:00
callebtc
7247d0ad5b feat(chat): constant-width two-check delivery marker that lights up green
Delivery/read markers previously swapped glyphs as acknowledgements
arrived, reflowing the text around them. Both checks now render from
the start in a disabled grey and simply recolour as the state advances
(delivered lights the first check, read lights both), so nothing ever
pushes message text around. Read receipts use the app's primary green
instead of the blue accent, a quick colour tween lights the checks up,
and the standalone marker adds a snappy scale pop when the state
advances. Applies to both matrix and bubbles modes.
2026-08-01 16:06:37 +02:00
callebtc
af91abab01 feat(chat): pull sender and delivery status into bubbles, thin-space hash suffix
Bubbles mode now reads like a classic messenger thread:

- the sender's name heads the first bubble of each run instead of
  floating above it; continuation bubbles skip it
- the delivery/read marker for own private messages trails the
  timestamp inside the bubble (same glyph mapping as the standalone
  marker); media rows keep the beneath-card marker since they have no
  inline text
- display names and their #abcd disambiguation suffix are now
  separated by a thin space (U+2009) in both matrix and bubbles modes
2026-08-01 15:05:41 +02:00
callebtc
7569fba7b4 feat(chat): align self media rows with bubbles chat UI mode
In bubbles mode, self-authored image, voice-note, and file rows now
align to the end side with the same tail-corner cue as text bubbles,
instead of sitting on the received side where they read as someone
else's content. Grouped self voice notes also keep the run on the
correct side. VoiceNotePlayer gains an optional modifier so the player
can hug the end side at a capped width. Received media and matrix mode
are unchanged.
2026-08-01 13:57:40 +02:00
heyaim
862fd8449d Hide the command and mention popups after a message is sent
When a slash command is sent without tapping the autocomplete list, the
command popup stays on screen with its old suggestions. The send path
clears the input field in code, and the popup is hidden only from the
field's text-change handler, which a code-driven clear does not run, so
the show flag is never turned off. The mention popup has the same cause.

CommandProcessor gains clearSuggestions(), which hides both popups and
empties their lists, the same resets the two select functions already do
inline. ChatScreen's send handler calls it where it already resets the
field.

The private chat sheet called updateMentionSuggestions on every keystroke
while rendering its own popups as hidden, so the only effect was on the
main composer behind it: typing @ in the sheet and then sending or
dismissing left a stale mention popup on the main screen. That call is
removed.

Tests in CommandProcessorTest open each popup and assert clearSuggestions
hides it. The unit tests do not drive Compose, so they cover the function
the send handler calls rather than the handler itself.

Full Android unit suite green; lint and the debug build pass.

Fixes #250.
2026-07-31 23:27:10 -05:00
callebtc
94d5fa3084 feat(chat): add bubbles chat UI mode with peer-color bubbles
Add a toggleable chat transcript style alongside the existing matrix
transcript. Bubbles mode renders text messages as classic messenger
bubbles: own messages on the right, peers on the left, each bubble
washed with the author's stable identity-derived peer colour so the
speaker stays identifiable without changing any theme, surface, or
background colours.

- ChatUiMode preference (Matrix / Bubbles, default Bubbles) persisted
  via ChatUiModeManager, mirroring ThemePreferenceManager
- BubbleTextMessageLayout: rounded bubble with a subtle tail on the
  speaker's side, width capped at 80% so long messages wrap; sender
  labels, grouping, timestamps, mentions, links, long-press, and the
  existing spring entry/placement animations are unchanged
- Delivery status for own private messages moves beneath the bubble in
  bubbles mode so it never overlaps the tail
- Chat style picker in About -> Settings reusing the ThemeChip pattern
2026-08-01 01:41:36 +02:00
callebtc
e07a38f634
Merge pull request #844 from permissionlesstech/codex/fix-wear-chat-auto-scroll
Fix Wear chat auto-scroll timing
2026-08-01 01:34:05 +02:00
callebtc
e907773b5f
Merge pull request #843 from permissionlesstech/codex/issue-753-live-ptt
feat: add live push-to-talk on Android and Wear OS
2026-08-01 01:33:53 +02:00
callebtc
e9bc8e0f31 docs(protocol): specify live voice v1 2026-08-01 00:42:32 +02:00
callebtc
44b2b8cb79 Fix first Wear message auto-scroll 2026-08-01 00:32:20 +02:00
callebtc
efeb9b54b5 Fix Wear chat auto-scroll timing 2026-07-31 17:51:16 +02:00
callebtc
cbc59aaf8b feat(voice): add live push-to-talk 2026-07-31 17:40:37 +02:00
callebtc
cb6d68958d
Merge pull request #842 from permissionlesstech/codex/update-agents-md
Update Agents MD
2026-07-31 16:09:16 +02:00
callebtc
e865a7f5b0 Update repository privacy instructions 2026-07-31 16:08:32 +02:00
Kane Waldo
83eaebc357 Fix geohash join channel routing 2026-07-31 11:20:58 +07:00
callebtc
e62e3bd1ea Replace Wear launcher wordmark with green BitChat glyph
Use the About-button pixel speech bubble in app primary green with BIT inside, on a white circular plate so the watch icon matches in-app branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 03:32:22 +02:00
callebtc
9b69c9f08e
Merge pull request #839 from permissionlesstech/docs/update-repository-guidelines
Update repository contributor guidelines
2026-07-31 03:28:24 +02:00
callebtc
5f58dbfa3d agents update 2026-07-31 03:19:45 +02:00
callebtc
229d93b54b
Merge pull request #826 from vincenzopalazzo/fix/file-tlv-forward-compat
Skip unknown file TLVs instead of dropping the transfer
2026-07-31 02:57:08 +02:00
callebtc
119b330e60
Merge pull request #835 from permissionlesstech/codex/readme-showcase
Add README feature screenshots
2026-07-31 02:40:58 +02:00
callebtc
13669b0d86
Merge pull request #837 from permissionlesstech/codex/readme-screenshot-skill
Add Android README screenshot studio skill
2026-07-31 02:40:02 +02:00
callebtc
e8e869dc40 Add Android README screenshot skill 2026-07-31 02:27:03 +02:00
callebtc
0e3a4fdd18 Refine README mesh conversation 2026-07-31 02:07:06 +02:00
callebtc
0f1eb56e66 Center README globe on the Middle East 2026-07-31 01:40:13 +02:00
areebahmeddd
4b2a9c3b0d
preserve original bytes 2026-07-31 04:59:22 +05:30
callebtc
4a6aa4875e Refresh README screenshots for Pixel layout 2026-07-31 00:19:53 +02:00
callebtc
daa6cfa532 Merge remote-tracking branch 'origin/main' into codex/readme-showcase 2026-07-31 00:08:45 +02:00
callebtc
286c8012a9 Clarify README screenshot descriptions 2026-07-31 00:00:59 +02:00
callebtc
55f8e5e72c Upgrade README screenshots to QHD 2026-07-30 23:57:57 +02:00
callebtc
13f15ca1ef
Merge pull request #836 from permissionlesstech/codex/restore-globe-picker-ux
Restore globe picker UX and teleport selection
2026-07-30 23:40:12 +02:00
callebtc
87184eddde globe fixes 2026-07-30 23:09:53 +02:00
callebtc
f34559a0e9 Add README feature screenshots 2026-07-30 19:43:26 +02:00
callebtc
1a792ecfec fix: harden unknown file TLV parsing 2026-07-30 19:33:02 +02:00
callebtc
daee720738
Merge pull request #834 from permissionlesstech/codex/restore-location-notes-entry
Restore location notes access
2026-07-30 19:32:52 +02:00
callebtc
8abe570374 Restore location notes access 2026-07-30 19:29:19 +02:00
callebtc
643f329d9d
Merge pull request #813 from qutad/fix/766-header-location-control
feat: improve location control accessibility and crowding
2026-07-30 19:12:51 +02:00
callebtc
ae70c02149
Merge pull request #833 from a1denvalu3/optimize/geohash-picker-low-end
Optimize geohash globe rendering and gestures
2026-07-30 19:08:38 +02:00
callebtc
aaac810f4f
Merge pull request #829 from TheCodeSmith404/fix/panic-mode-memory-purge-807
fix(panic): clear in-memory networking caches, router outbox, and Nostr queues on panic wipe (#807)
2026-07-30 16:33:06 +02:00
a1denvalu3
0d7f8cef10 Prevent whole-globe land fills 2026-07-30 15:50:21 +02:00
a1denvalu3
6edd361ed3 Fix globe land fill orientation 2026-07-30 15:33:17 +02:00
a1denvalu3
6fc4583a15 Stabilize globe quality selector layout 2026-07-30 15:09:29 +02:00
callebtc
fa4442d836
Merge pull request #832 from permissionlesstech/codex/fix-conversation-alias-isolation
Prevent private messages from merging across conversations
2026-07-30 14:54:58 +02:00
a1denvalu3
8602db88de Add geohash globe render quality selector 2026-07-30 14:39:29 +02:00
a1denvalu3
5bde4e0b4a Close fully visible geohash outlines 2026-07-30 14:29:22 +02:00
a1denvalu3
2a43265e12 Optimize geohash globe rendering and gestures 2026-07-30 14:01:02 +02:00
callebtc
1a953a17ab Fix private conversation alias isolation 2026-07-30 12:36:46 +02:00
callebtc
b692ec7b44
Merge pull request #828 from moehamade/fix/hotspot-group-consent
fix(hotspot): never disturb another app's Wi-Fi Direct group without consent
2026-07-30 12:30:00 +02:00
TheCodeSmith404
e311649525 fix(panic): clear in-memory networking caches, router outbox, and Nostr queues on panic wipe (#807) 2026-07-30 11:04:17 +05:30
callebtc
b6ad8971a8 fix(hotspot): revalidate ownership during teardown 2026-07-30 03:20:48 +02:00
callebtc
1699cc7986 Header: preserve compact channel accessibility state 2026-07-30 03:00:49 +02:00
callebtc
33538fa9e0
Merge pull request #827 from a1denvalu3/fix/stale-peer-cleanup
Fix stale peer lifecycle cleanup
2026-07-30 02:41:13 +02:00
Moe Hamade
5859ac29cd fix(hotspot): confirm a group is ours before trusting or removing it
Two related ways the app could act on a group it did not create.

createdGroup was set once and never revisited, so it outlived the group it
described. Our group can disappear without us - Wi-Fi toggled, another app
issuing its own device-scoped removeGroup(), a driver reset - and another
app can create one in its place while the sharing screen is still open.
Stop then believed the group present was ours and removed it, disconnecting
a session the user never agreed to touch. Every group snapshot now
reconciles the flag against what is actually on the framework.

isGroupOwner cannot carry that judgement on its own: it reports that this
DEVICE hosts the group, which is equally true of an autonomous group
another app created here. So a replacement group was still being adopted -
its name persisted as ownedGroupName, its credentials shown as ours below Q
- and because ownership is now an exact-name match, the next start
classified that name as ours and removed it with no confirmation. Snapshots
are only read once the group is confirmed to be the one we created: above Q
by the network name we chose, below Q by the first name the framework
reported for our own successful creation, fixed from then on.

Reconciliation waits until our group has been named, because a null
snapshot during formation says nothing about a group that has not appeared
yet. Losing the flag to a transient null is safe in a way that keeping it
is not - the group we created is left behind, and the next start recognises
it by name and removes it silently.

A group-info reply arriving after the stop is now ignored, so it cannot
revive state the stop just cleared.

Device-verified on a Pixel 9a (Android 16, API 37).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:40:17 +03:00
Moe Hamade
141adf5468 fix(hotspot): only remove our own group; ask consent to replace a foreign one
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>
2026-07-30 01:28:09 +03:00
Vincenzo Palazzo
46de1a3623 fix: skip unknown file TLVs instead of dropping the transfer
`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.
2026-07-30 00:11:18 +02:00
Moe Hamade
3562ad10d5 fix(wifi-aware): count hotspot radio holds and never drop a restart request
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>
2026-07-30 00:50:36 +03:00
a1denvalu3
6ebbae0326 Rerun CI 2026-07-29 23:35:53 +02:00
a1denvalu3
9f0223591f Fix stale peer lifecycle cleanup 2026-07-29 23:15:01 +02:00
callebtc
97d6e8a479
Merge pull request #825 from permissionlesstech/codex/network-tab-list-cleanup
Polish Network list badges and previews
2026-07-29 22:32:04 +02:00
callebtc
1774414f2e
Merge pull request #819 from spradhan7656/main
android 10 or below chat ui input field padding issue
2026-07-29 22:17:16 +02:00
callebtc
5dd7b2c809 Restore Gradle verification metadata 2026-07-29 21:57:41 +02:00
callebtc
a5dfe9fe42 Polish network conversation rows 2026-07-29 21:56:12 +02:00
callebtc
2176160ac0
Merge pull request #823 from permissionlesstech/ui/geohash-globe-picker
feat: animated 3D globe geohash picker with borders and cities
2026-07-29 21:55:07 +02:00
callebtc
57cc39dbdf Merge main into fix/766-header-location-control 2026-07-29 20:52:00 +02:00
callebtc
fe93be379a Remove generated Gradle daemon JVM config 2026-07-29 20:45:41 +02:00
callebtc
44f62a3c14
Merge pull request #776 from a1denvalu3/feat/cashu-chips-android
feat: add Cashu ecash chips and /pay command
2026-07-29 20:21:12 +02:00
callebtc
03de6f3519 fix: allow targeting polar geohashes
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.
2026-07-29 20:21:12 +02:00
callebtc
b073dc2160
Merge pull request #824 from permissionlesstech/codex/fix-peer-verification-status-badge
Fix peer verification status and private chat badge
2026-07-29 20:18:34 +02:00
callebtc
70185ae86f Refine private chat header actions 2026-07-29 19:40:21 +02:00
callebtc
79bf154ce3 feat: overlay country borders and cities on the globe picker
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
2026-07-29 19:39:51 +02:00
callebtc
29f9eb0cbc
Merge pull request #821 from permissionlesstech/codex/fix-android-studio-strict-build
fix: restore Android Studio sync with strict Gradle security
2026-07-29 19:39:16 +02:00
callebtc
c7ced41bcb
Merge pull request #822 from permissionlesstech/uniform-lists-design
Unify people lists with conversation-row avatar design
2026-07-29 19:39:07 +02:00
callebtc
9f81a1e40d Fix peer verification status and badge 2026-07-29 19:32:28 +02:00
callebtc
9ca1413a1f feat: replace Leaflet WebView geohash picker with animated 3D globe
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.
2026-07-29 19:28:16 +02:00
callebtc
79e04ed3cc
Merge pull request #820 from permissionlesstech/codex/fix-wear-private-message-navigation
Improve Watch private-chat navigation and peer identity UI
2026-07-29 19:23:43 +02:00
callebtc
a306340119 Preserve Watch navigation across recreation 2026-07-29 19:12:13 +02:00
callebtc
4fdc9babbe feat: unify people lists with conversation-row avatar design
- 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
2026-07-29 19:11:06 +02:00
a1denvalu3
199f95fdbd Fix Cashu payment review issues 2026-07-29 19:09:05 +02:00
callebtc
3b0e569882 ci: use exact Temurin catalog version 2026-07-29 19:08:35 +02:00
a1denvalu3
6f4f6b988b Keep Cashu composer preview compact 2026-07-29 19:07:31 +02:00
a1denvalu3
adce27c0d1 Preview Cashu tokens in message composer 2026-07-29 19:07:12 +02:00
a1denvalu3
b9ca8ed3eb Add Cashu payment chips and pay command 2026-07-29 19:06:50 +02:00
callebtc
4f567ecd5f
Merge pull request #800 from a1denvalu3/issue-764-language-picker
Add in-app language picker
2026-07-29 19:03:01 +02:00
callebtc
44560b9792 Add Watch peer profiles and verification 2026-07-29 18:55:56 +02:00
callebtc
bf5bd8c417 fix: restore Android Studio sync with strict Gradle security 2026-07-29 18:15:50 +02:00
callebtc
d465f427a4 wear: fix private message navigation 2026-07-29 18:10:25 +02:00
Santosh Pradhan
1c3626e0a0
Merge pull request #1 from spradhan7656/chat-ui-issue
fix the ui issue in the chat screen
2026-07-29 20:56:34 +05:30
spradhan7656
31d822e7d6 fix the ui issue in the chat screen 2026-07-29 20:54:15 +05:30
spradhan7656
6288cd3cc1 fix the ui issue in the chat screen 2026-07-29 20:47:39 +05:30
callebtc
5b79da2db6
Merge pull request #816 from permissionlesstech/codex/fix-active-peer-notifications
Restore background peer availability alerts
2026-07-29 15:53:24 +02:00
callebtc
00ef72c45e fix: clear peer alerts on mesh shutdown 2026-07-29 15:49:33 +02:00
callebtc
e99d5ac4a0 Merge remote-tracking branch 'origin/main' into codex/fix-active-peer-notifications 2026-07-29 15:29:56 +02:00
callebtc
2807a16d1e fix: rate-limit peer availability alerts 2026-07-29 15:29:48 +02:00
callebtc
56f91a96b7
Merge pull request #817 from permissionlesstech/codex/android-ui-visual-review-skill 2026-07-29 15:21:58 +02:00
callebtc
95d6922218 feat: add Android UI visual review skill 2026-07-29 15:15:30 +02:00
callebtc
4fad876944 Merge main and resolve notification conflicts 2026-07-29 15:14:25 +02:00
callebtc
3410aaf5b9
Merge pull request #815 from permissionlesstech/codex/polish-persistent-conversations-v2
Polish persistent private conversations
2026-07-29 15:05:42 +02:00
callebtc
11511df8c3 style: remove emoji from peer alert title 2026-07-29 15:03:25 +02:00
callebtc
363f8c5aae fix: restore background peer availability alerts 2026-07-29 15:00:13 +02:00
callebtc
a21c0242a6 Fix conversation persistence review issues 2026-07-29 14:43:13 +02:00
callebtc
172d086c23 Fix live conversation identity state updates 2026-07-29 14:23:25 +02:00
callebtc
889c262edc
Merge pull request #802 from permissionlesstech/codex/mesh-lab-agent-skill
Add Mesh Lab agent skill
2026-07-29 13:55:55 +02:00
callebtc
c382c17a92 mesh-lab: fail closed when evidence dir is empty
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.
2026-07-29 13:54:50 +02:00
callebtc
a105d884cb Polish persistent private conversations 2026-07-29 13:51:55 +02:00
callebtc
9ed5cb26da
Merge pull request #798 from permissionlesstech/codex/reproducible-builds
Enable reproducible Android builds and releases
2026-07-29 13:47:45 +02:00
caly
ad05bc5ed5 Header: improve location control accessibility and crowding 2026-07-29 14:20:17 +03:00
callebtc
01b59a7a0c
Merge pull request #809 from permissionlesstech/voice-recording-ux
Voice recording UX: slide-to-cancel, magnetic target, refined recording pill
2026-07-29 13:02:49 +02:00
callebtc
6f60b151d0
Merge pull request #810 from permissionlesstech/codex/wear-notification-stream-fix
Fix Wear OS DM notification delivery
2026-07-29 13:02:25 +02:00
callebtc
1dae188cdf
Merge pull request #806 from permissionlesstech/codex/persistent-conversations
Persist private conversations in People
2026-07-29 13:00:57 +02:00
callebtc
6a904a1849 voice: compute cancel verdict from the final pointer coordinate - recomposed shouldCancel state could be one frame stale on a fast slide-and-lift (PR review) 2026-07-29 12:55:36 +02:00
callebtc
538701c753 wear: shorten mesh status notification 2026-07-29 12:48:12 +02:00
callebtc
c6e64b1da8 wear: fix DM notification delivery 2026-07-29 12:42:54 +02:00
callebtc
ff0578cb88
Merge pull request #808 from moehamade/fix/hotspot-wifi-direct-reliability
fix: make Wi-Fi Direct hotspot sharing reliable
2026-07-29 12:33:16 +02:00
Moe Hamade
b5fd346437 fix: test the hotspot hold under the lock that publishes Aware
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>
2026-07-29 13:27:53 +03:00
callebtc
7c67df4b30 fix: isolate canonical container workspace 2026-07-29 12:24:09 +02:00
callebtc
8eddcb15ca voice: keep pill height identical between rest and recording - constrain recording visualizer row to the text field's 22dp content height so the separator no longer shifts 2026-07-29 12:23:50 +02:00
callebtc
97026d7bbb fix: lock wear dependencies in CI 2026-07-29 12:20:48 +02:00
callebtc
56f52218ed voice: watch-quality recording UX - slide-to-cancel with magnetic X target (leans toward finger, blushes red with proximity, snaps on hover, REJECT haptic on cancel, firm click on enter/exit), release-anywhere tracking replaces button-owned release, timestamp left of waveform showing elapsed only, muted 1.5dp red outline, neutral grey recording pill with depth 2026-07-29 12:20:30 +02:00
Moe Hamade
05896f354d fix: route every hotspot failure through one teardown path
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>
2026-07-29 13:09:04 +03:00
callebtc
dcb77dc6b1 Merge remote-tracking branch 'origin/main' into codex/reproducible-builds 2026-07-29 12:08:12 +02:00
Moe Hamade
41544a6840 fix: make Wi-Fi Direct hotspot sharing reliable
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>
2026-07-29 12:54:01 +03:00
callebtc
bf87a80617 Honor private message admission during panic 2026-07-29 11:00:24 +02:00
callebtc
c8153c0d18 Keep Wear shared state compatible 2026-07-29 03:57:28 +02:00
callebtc
e4f0713121 Merge remote-tracking branch 'origin/main' into codex/persistent-conversations 2026-07-29 03:53:34 +02:00
callebtc
c5ff1ca59a Complete persistent conversation lifecycle 2026-07-29 03:53:29 +02:00
callebtc
d3f01f27c6 persistent 2026-07-29 03:41:24 +02:00
callebtc
7fad2f2202 persist 2026-07-29 03:41:16 +02:00
callebtc
5edc9f7d85 docs: add maintainer release runbook 2026-07-29 03:05:01 +02:00
callebtc
3b53b9bdcc
Merge pull request #805 from permissionlesstech/wear-os-1
Wear OS app: standalone bitchat client for Pixel Watch
2026-07-29 03:02:46 +02:00
callebtc
e4ad09830b wear: address PR review - (1) DM send no longer echoes 'Sent' pre-handshake: session-gated echo stays 'Sending' and retries for 15s after initiating Noise handshake; (2) minSdk 33 (Wear OS 4): API 30 lacks the S+ Bluetooth permissions and would require location for scan, which the app refuses; (3) recreate BluetoothConnectionManager when !isReusable() so stop->start cycles don't leave a zombie mesh 2026-07-29 02:54:11 +02:00
callebtc
120cc8e901 docs: wear screenshots for PR - onboarding, chat, people, voice notes, recording overlay 2026-07-29 02:39:29 +02:00
callebtc
9998e7ae3f wear: fluid magnetic slide-to-cancel - cancel button leans toward the approaching finger and blushes green-to-red continuously with proximity (finger-driven), soft springy scale bloom on activation; rotation wobble removed 2026-07-29 02:28:07 +02:00
callebtc
18831c20fa wear: slide-to-cancel voice recording - global finger tracking while recording, mic button morphs into red cancel target on approach (tick haptic in/out, double-click reject on cancel), release anywhere sends; PTT release no longer owned by the small mic button; haptics: knock on record start/incoming message, click on stop/send, tick on text send 2026-07-29 02:22:40 +02:00
callebtc
2e3f7bf827 voice improvments 2026-07-29 02:12:55 +02:00
callebtc
b3b478be10 wear: copy polish - compact notification-permission and nickname-edit descriptions, sentence-case labels across all screens (buttons, placeholders, empty states, prompts) 2026-07-29 02:07:08 +02:00
callebtc
da5650ceb4 fix: keep release signing local 2026-07-29 02:06:43 +02:00
callebtc
c1e6204686 wear: rename from People screen - 'you' row first opens nickname editor; nickname field auto-focuses with cursor at end, IME Done only closes keyboard for review, confirm button is the single commit path, trim while typing 2026-07-29 02:04:09 +02:00
callebtc
86a04bc8a2 wear: chat header fits round screen with unread (title yields to people+mail icons with counts, whole header taps to People); People screen: capitalized title, Noise lock icon instead of 'noise ✓' text, RSSI removed, unread senders float to top with mail icon + count 2026-07-29 01:43:04 +02:00
callebtc
28261af55c wear: add DM notifications and refresh launcher assets 2026-07-29 01:23:17 +02:00
callebtc
2e8f6ffc35 wear: shrink header to dense form on scroll-up instead of hiding it - overlay-only size animation, list geometry untouched 2026-07-29 01:08:11 +02:00
callebtc
92f60bd02f wear: simplify chat scrolling to the classic messenger pattern - constant list padding with header/action bar as floating overlays driven by one scroll-direction state; removes the animated bottom clearance and in-layout header resizing that shifted content mid-gesture 2026-07-29 01:04:44 +02:00
callebtc
f5915c4c31
Merge pull request #804 from permissionlesstech/codex/fix-private-media-contact-routing
Fix private media sending from contact conversations
2026-07-29 00:54:47 +02:00
callebtc
80342a74e8 wear: fix dock/undock flapping when scrolling up from bottom - undocking collapsed the 48dp bottom clearance which clamped scroll back to the end and re-docked in a loop; buttons now toggle on a fast 12dp overlay-only channel while the geometry-changing docked state requires 60dp of upward scroll (> 48dp padding delta) 2026-07-29 00:52:07 +02:00
callebtc
c7720d3847 Fix private media routing for contact conversations 2026-07-29 00:43:39 +02:00
callebtc
8f4c937e92 wear: fix action bar disappearing at bottom - replace fragile distance-to-last-item geometry (dead-zoned by expanded padding, skewed by edge-scaling) with scroll-intent logic: canScrollForward=false always docks + shows buttons, any downward scroll reveals them, upward scroll hides 2026-07-29 00:32:45 +02:00
callebtc
c06fa67ccc wear: fix header size flip-flopping - drive header from the debounced dockedAtNewest state instead of raw per-frame layout geometry; header is full-size at the newest, compacts when browsing history 2026-07-28 22:24:35 +02:00
callebtc
156a5f3218 wear: perf - debug-signed R8 release build for on-device testing, Gson keep rules, cache time formatter, memoize file chip stat 2026-07-28 22:16:32 +02:00
callebtc
fb93d2d85f wear: TransformingLazyColumn chat lists - native center-scaling/rotary/scrollbar, exact scroll-to-end autoscroll, Arrangement.Bottom anchoring, consolidated ChatScaffold 2026-07-28 22:06:51 +02:00
callebtc
d4aa9b1644 wear: fix padding oscillation with hysteresis (expand <40, collapse >120) - padding delta can no longer retrigger itself 2026-07-28 21:40:51 +02:00
callebtc
f64338a3b8 wear: dynamic bottom padding - last message clears floating buttons at newest, text flows behind them when scrolled up 2026-07-28 21:38:15 +02:00
callebtc
5e937fa816 wear: action buttons float over chat text instead of reserving space; empty-state lifted clear of buttons 2026-07-28 21:34:14 +02:00
callebtc
5797ee18c0 wear: fix inverted scroll direction and restore scrollbar - normal top-down scrollable bottom-anchored via Box alignment, ScreenScaffold scroll indicator, standard rotaryScrollable defaults 2026-07-28 21:30:08 +02:00
callebtc
e03e373caa plan 2026-07-28 20:58:17 +02:00
callebtc
71c257669a watch works 2026-07-28 20:55:15 +02:00
callebtc
32a04b69b0
Merge pull request #801 from permissionlesstech/codex/share-local-arm64-apk
Support sharing the installed ARM64 APK
2026-07-28 19:46:33 +02:00
callebtc
a3fffd482f docs: add Mesh Lab agent skill 2026-07-28 19:23:02 +02:00
callebtc
57b002917f Check updates for cached universal APK 2026-07-28 18:50:15 +02:00
callebtc
9800927b59 Support sharing installed ARM64 APK 2026-07-28 18:42:01 +02:00
callebtc
bd3bb8c774 wear: M7 - black splash theme, final design review, build/run/test docs; all milestones except deferred M5 done 2026-07-28 18:41:22 +02:00
callebtc
6340976e1c wear: M2/M3/M4/M6 verified on hardware - mesh discovery, chat, Noise DMs, ambient survival, full mesh_lab suite green; composer UX fixes 2026-07-28 18:31:45 +02:00
callebtc
a0048554c6 fix: isolate container SDK configuration 2026-07-28 17:50:17 +02:00
callebtc
bd29257b73
Merge pull request #799 from permissionlesstech/codex/fix-hotspot-local-network-permission
Fix Android 17 local network permission for hotspot sharing
2026-07-28 17:32:46 +02:00
callebtc
4640d8479c docs: M7 status in wear plan 2026-07-28 16:52:36 +02:00
callebtc
ecad00c854 wear: M7 code - message appear animations, screen transitions, auto-scroll, unread DM badge 2026-07-28 16:52:14 +02:00
callebtc
43a88808a5 wear: M2/M3/M4/M6 code - BLE mesh service, chat/DM/people UI, test hook, mesh_lab watch support (hardware verification pending) 2026-07-28 16:43:49 +02:00
aharshit123456
df62505b59 i18n: backfill 7 severely incomplete locale translations
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>
2026-07-28 16:37:24 +02:00
a1denvalu3
bbc7dc95a1 align selected language to row end 2026-07-28 16:30:50 +02:00
a1denvalu3
525ba27e3b refine language picker for iOS parity 2026-07-28 16:24:11 +02:00
callebtc
711c73bbe7 wear: M1 - compile shared bitchat stack (protocol/noise/mesh) into :wear via synced sourcesets, 172 shared tests green 2026-07-28 16:19:56 +02:00
a1denvalu3
8f2585d8eb feat: add in-app language picker 2026-07-28 16:18:01 +02:00
callebtc
42a8edf8ed fix: request local network access for hotspot sharing 2026-07-28 16:06:12 +02:00
callebtc
a355ed79d0 wear: M0 - scaffold :wear module with bitchat theme, verified on Pixel Watch 3 2026-07-28 15:54:41 +02:00
callebtc
11d0bd794e
Merge pull request #797 from permissionlesstech/feature/mesh-test-hooks
ADB-driven mesh test hooks + two-device lab; fix undeliverable oversized broadcast files
2026-07-28 15:27:24 +02:00
callebtc
11f39c27f4 docs: complete mesh lab guide (prereqs, device prep, scenarios, troubleshooting)
Expands the release-gate runbook appendix into a fresh-clone walkthrough
and points AGENTS.md at the ADB device mesh tests, noting they are
deliberately separate from Gradle/CI.
2026-07-28 15:24:03 +02:00
callebtc
8827a07b11 mesh_lab: add session_recovery and identity_reset churn scenarios
- session_recovery: force-stop B mid-session; assert identity persists,
  mesh rejoins, re-handshake completes, DMs flow again
- identity_reset: pm clear B mid-session; assert new identity,
  rediscovery, handshake and DM round trip
- Helpers: wake() (foreground the app so BLE leaves POWER_SAVER),
  reset_bluetooth() (clear zombie GATT links), ensure_direct_link()
  (explicit GATT connect instead of waiting out duty cycles),
  force_handshake() with retries
2026-07-28 15:18:31 +02:00
callebtc
3f8b596361 build: enable reproducible Android releases 2026-07-28 14:29:49 +02:00
callebtc
3eb9ac20b0 mesh_lab: clear receiver incoming dirs before file scenarios
Incoming files are name-uniquified on receipt (e.g. 'small_1k (1).bin'),
so repeated runs broke the name-based file_recv matcher.
2026-07-28 14:01:21 +02:00
callebtc
fbb13a33d5 Reject broadcast sends exceeding the receiver fragment cap
Receivers hard-cap reassembly at MAX_FRAGMENTS_PER_ID (256), but the
generic send path fragmented packets with no caller cap (0xFFFF), so
broadcast file transfers above ~120 KB were fully transmitted yet
undeliverable. FragmentingPacketSender now caps fragmentation at
MAX_FRAGMENTS_PER_ID and reports failure via a new
TransferProgressEvent.failed flag, which surfaces as
DeliveryStatus.Failed in the UI and as a file_send error in the debug
test hook instead of an indefinite wait.

Adds FragmentingPacketSenderTest and a file_oversize mesh-lab scenario
asserting sender-side rejection.
2026-07-28 13:17:35 +02:00
callebtc
41494d7c16 Add ADB-driven mesh test-hook framework and two-device mesh lab
Debug-only broadcast receiver (app/src/debug) exposes mesh operations over
ADB: scan, connect, Noise handshake, DMs, broadcast, announce, file
send/receive with SHA-256 verification, BLE toggle, state dumps, and raw
packet injection. tools/release_gate/mesh_lab.py orchestrates scenarios
(dm, broadcast, file, file_private, raw) on two live devices and emits
evidence JSON.
2026-07-28 13:05:49 +02:00
callebtc
96340a35aa fix string 2026-07-28 00:57:30 +02:00
callebtc
57d11299da
Merge pull request #781 from permissionlesstech/codex/background-power-optimization
Centralize adaptive background power scheduling
2026-07-28 00:15:08 +02:00
callebtc
4658a3961f
Merge pull request #795 from permissionlesstech/fix/onboarding-skip-button-alignment
Fix onboarding skip-button jump between permission screens
2026-07-28 00:14:23 +02:00
callebtc
15a580aeef
Merge pull request #796 from permissionlesstech/codex/private-message-arrival-order
Fix private message ordering across clock skew
2026-07-27 23:50:05 +02:00
callebtc
c54fdcd9fb Merge remote-tracking branch 'origin/main' into codex/background-power-optimization
# Conflicts:
#	app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
#	app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt
#	app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt
2026-07-27 23:29:56 +02:00
callebtc
62926f88d5 fix: preserve arrival order across aliases 2026-07-27 23:29:01 +02:00
callebtc
80f774dd0b update 2026-07-27 23:20:57 +02:00
callebtc
79a44ba546 update 2026-07-27 23:20:32 +02:00
callebtc
5fd4067ba4 readme 2026-07-27 23:19:51 +02:00
callebtc
3484ff5ccf readme 2026-07-27 23:18:15 +02:00
callebtc
5b13598bd0
Merge pull request #794 from permissionlesstech/kimi/dm-outbox-retry-scheduler
Retry scheduler for queued DMs and Noise session re-establishment
2026-07-27 23:13:23 +02:00
callebtc
dfe4fba212 fix: preserve private message arrival order 2026-07-27 22:50:27 +02:00
callebtc
3c150d91e0 fix: unify outbox locking and tie retry scheduler to service lifecycle 2026-07-27 22:48:10 +02:00
callebtc
b79276f236 Tweak KDoc for onboarding footer alignment note.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:42:22 +02:00
callebtc
673f65d052 Fix onboarding skip-button jump between permission screens.
Align the background location footer with the battery optimization layout so primary, check-again, and skip stay at the same height when navigating between them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:42:15 +02:00
callebtc
7f2e684572 feat: retry scheduler for queued private messages and session re-establishment 2026-07-27 22:33:28 +02:00
callebtc
d814ac7f80
Merge pull request #729 from permissionlesstech/codex/bound-compressed-payload-expansion
Bound pre-auth compressed payload expansion
2026-07-27 22:28:26 +02:00
callebtc
3f6ac31262
Merge pull request #793 from permissionlesstech/ui/theme-aware-peer-colors
ui: theme-aware muted peer colors
2026-07-27 22:26:48 +02:00
callebtc
ea1c64d2b3
Merge pull request #784 from permissionlesstech/codex/unread-dm-rows
Keep unread DM senders visible in the people sheet
2026-07-27 22:26:31 +02:00
callebtc
0721c39f89 Route oversize send failure to the active conversation
Addresses review feedback: the size-cap error was posted to the main
mesh timeline, so a user sending from a private chat or channel never
saw it. rejectIfOversized now posts to the private conversation
(addPrivateMessageNoUnread) or channel (addChannelMessage) the send
originated from, falling back to the main timeline for public sends.

Adds regression tests for both routings.
2026-07-27 22:24:02 +02:00
callebtc
d5cef681e4 Merge remote-tracking branch 'origin/main' into codex/unread-dm-rows
# Conflicts:
#	app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
#	app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt
2026-07-27 22:16:13 +02:00
callebtc
773b21500f Merge remote-tracking branch 'origin/main' into codex/unread-dm-rows
# Conflicts:
#	app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
#	app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt
2026-07-27 22:12:03 +02:00
callebtc
c3bc911bcd Finish rollout: user-visible size-cap failure, OOM-safe decode catch
- MediaSendingManager: surface a chat system message when a picked file
  exceeds the ~10 MiB send cap instead of silently dropping the send
  (voice/image/file paths), completing the 'user-visible failure'
  requirement of the rollout gate
- BinaryProtocol: catch Exception instead of Throwable in decodeCore so
  OutOfMemoryError is never swallowed and masked as a parse failure
- docs/file_transfer.md: mark the compressed-expansion rollout gate
  resolved; support for legacy >10 MiB compressed transfers is
  explicitly ended
2026-07-27 22:11:19 +02:00
callebtc
35a916a3a5
Merge pull request #789 from permissionlesstech/codex/fix-read-receipt-reliability
fix: make read receipts reliable
2026-07-27 22:11:04 +02:00
a1denvalu3
b251812b9e Align compressed payload send and receive bounds (#736)
* Align compressed payload send and receive bounds

* Preserve ambiguous raw deflate compatibility

* Pool decompression by memory budget
2026-07-27 22:10:27 +02:00
jack
e9bdf8aefc Bound pre-auth compressed payload expansion 2026-07-27 22:10:27 +02:00
callebtc
7dbd65b4d0 fix: honor receipt transport acceptance 2026-07-27 22:08:10 +02:00
callebtc
719b3d1895 ui: mute theme-aware peer colors for contrast
Extract PeerColorStyle so each palette owns saturation/value, keeping
hues stable while dark mode stays bright-but-muted and light mode avoids
neon labels. New themes only need to supply their own style.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:06:46 +02:00
callebtc
c39bb8124c
Merge pull request #792 from permissionlesstech/kimi/improve-noise-handshake-reliability
Improve Noise handshake reliability and offline session recovery
2026-07-27 22:00:56 +02:00
callebtc
83b2c51301 fix: keep contact nickname in private chat title when peer goes offline 2026-07-27 21:58:17 +02:00
callebtc
c76c233aae handshake robustness 2026-07-27 21:46:24 +02:00
callebtc
354f1c28cd
Merge pull request #790 from permissionlesstech/ui/people-sheet-favorited-star
ui: three-state favorite stars in people sheet
2026-07-27 21:26:59 +02:00
callebtc
10cc96d6e8
Merge pull request #791 from permissionlesstech/ui/enable-location-in-nearby
ui: move enable location services into nearby section
2026-07-27 21:26:46 +02:00
callebtc
7a26fccff7 ui: move enable location services into nearby section
Keep disable at the sheet footer so enabling is next to nearby channels while turning location off stays a secondary action.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 21:18:49 +02:00
callebtc
32d1916ec0 ui: show three-state favorite stars in people sheet
Mirror the private-chat header so peers who favorited us get an orange outline until we favorite back.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 21:11:37 +02:00
callebtc
290d72f2b6 fix: make read receipts reliable 2026-07-27 20:03:17 +02:00
callebtc
0f312a13f0
Merge pull request #788 from permissionlesstech/feat/favorited-star-wobble
ui: celebrate being favorited in private chats
2026-07-27 19:11:59 +02:00
callebtc
5fd686a00a ui: celebrate being favorited in private chats
- header star wobbles and its outline turns orange when the peer
  favorites you; it only fills once you favorite them back
- mirror the 'favorited you' system notice into the private
  conversation (mesh path), matching the main chat
- expose reactive peerFavoritedUs state driven by
  FavoritesPersistenceService; keep system notices silent
  (no unread badge, read receipt or push)
2026-07-27 19:11:37 +02:00
callebtc
90b00ac557
Merge pull request #787 from permissionlesstech/fix/noise-after-Prs
Restore robust Noise handshakes and mesh DMs
2026-07-27 18:49:54 +02:00
callebtc
62dd3ca90e fix: complete direct-link routing rollback 2026-07-27 18:47:17 +02:00
callebtc
f3c4571cc0
Merge pull request #786 from permissionlesstech/ui/noise-open-lock-transition
ui: open lock until Noise session is established
2026-07-27 18:46:15 +02:00
callebtc
58c56a8082 ui: open lock until Noise session is established
Show ic_spec_lock_open while idle or handshaking, then Crossfade to the
closed lock on success or failure — timed with the existing tint wash so
the shackle settling reads as one smooth transition.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:45:29 +02:00
callebtc
6dff0844bb revert noise 2026-07-27 18:44:49 +02:00
callebtc
fa63a98e77 Update Nostr handler regression test 2026-07-27 18:28:05 +02:00
callebtc
a06c9f8614 Merge remote-tracking branch 'origin/main' into codex/background-power-optimization 2026-07-27 18:26:49 +02:00
callebtc
d8e380efae Fix background Nostr event processing 2026-07-27 18:23:26 +02:00
callebtc
21cfa62cc2 fix: address unread DM review feedback 2026-07-27 18:23:08 +02:00
callebtc
79b1e53d07
Merge pull request #785 from permissionlesstech/ui/noise-session-lock-glow
ui: glow lock for Noise handshake instead of sync icon
2026-07-27 18:15:35 +02:00
callebtc
1a390b5a1f ui: glow lock for Noise handshake instead of sync icon
Match private-chat Noise status to the Tor globe treatment: one lock
glyph with an orange pulse while handshaking, then green or red with
smooth tint cross-fades — no sync/recycle swap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:13:46 +02:00
callebtc
6325099e2e
Merge pull request #782 from permissionlesstech/codex/fix-nostr-dm-timestamps
Fix randomized timestamps in Nostr direct messages
2026-07-27 18:11:49 +02:00
callebtc
8c62e90711 ui: keep unread DM senders visible 2026-07-27 18:01:08 +02:00
callebtc
b22184940b Harden background relay lifecycle 2026-07-27 18:00:10 +02:00
callebtc
67b0ae78a5
Merge pull request #783 from permissionlesstech/fix/conversation-header-close-button
ui: use shared green CloseButton in private and channel headers
2026-07-27 17:58:46 +02:00
callebtc
81a35d9dba ui: use shared green CloseButton in private and channel headers
Align conversation exit controls with bottom-sheet close chrome so the X reads as primary green rather than muted grey.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 17:58:36 +02:00
callebtc
85cbe20b39 fix: use authenticated timestamps for Nostr DMs 2026-07-27 17:56:48 +02:00
callebtc
6703f1bd20 Merge main into background power optimization 2026-07-27 17:47:58 +02:00
callebtc
7025009788
ui: complete the redesign — palette, top bar, composer, About, and a motion pass (#774)
* first pass

* pass 2

* cleanup

* capitalization

* strings

* input bar fixes

* fixes

* notes

* nice

* nicer

* lists

* cleanup

* button

* fixes

* animations

* Fix layout jumpiness in chat and geohash people list

Three separate causes of things moving when they should not:

- Chat lurched whenever a bottom sheet closed. Placement animation is meant
  to soften insertions and removals, but any relayout moves every item --
  a sheet's text field opening the keyboard changes the chat's IME inset,
  and closing it changes it back. Placement animation is now armed only
  briefly around a real change to the message list, so items otherwise
  track the viewport exactly.

- Anon list changed height as participants churned. Rows sized to their
  content, so any reorder could change the card's height; and the card
  sized to the live anon count, which moves constantly in a busy geohash.
  Rows now have an exact height, and a trimmed anon card reserves the full
  capped height regardless of how many are present beyond the cap.

- Anons are now their own trailing section rather than a tail on each of
  "on location" and "teleported in", which had pushed the few recognisable
  names out of view twice over. Self is never grouped as an anon.

Adds 7 tests covering the sectioning and the fixed-length behaviour.

* Group geohash people as People and Anon

Replaces the "on location" / "teleported in" / "anonymous" split with two
sections: peers who announced a nickname, then the anons.

Teleport state was never worth a section of its own -- every row already
carries it as a distinct glyph -- and splitting on it fragmented the short
list people actually read, in a channel where most participants are
anonymous anyway.

Self stays in the People section even when unnamed.

* Key message list state per conversation

Switching channels reused every piece of state in MessagesList, because none
of it was keyed on which conversation was being shown:

- The LazyListState carried the previous channel's scroll offset, so the new
  channel opened at a stale position and then corrected itself.
- hasScrolledToInitialPosition and followIncomingMessages carried over, so a
  channel entered after scrolling up in another one did not land on its
  newest message at all.
- The arrival tracker had never seen the incoming channel's ids, so a
  backlog of six or fewer messages was treated as six simultaneous arrivals
  and each one slid in.
- previousMessageCount carried over, arming placement animation for the
  relayout that the switch itself caused.

All of it is now keyed on a conversationKey derived the same way
displayMessages is. The tracker also detects a list sharing no ids with the
previous one and adopts it silently, which covers /clear and any caller that
does not supply a distinct key.

Adds 4 tests for wholesale replacement, including the case that the burst
cap cannot catch on its own.

* fix location channel layout

* icon

* location sheet

* move location error

* fix location channel lifecycle bug

* remove empty lable

* geist mono

* timestamp no seconds

* new icons

* icons

* cleanup

* mentions

* fix mentions

* grouping of geohash channel list

* colors

* fix mention colors

* Bring private and group chat headers up to the main header's layout

Both conversation headers were built on TopAppBar with a centred title, a
back arrow on the left and everything else crowded into the title slot, at
14sp with 14dp icons. Moving between the timeline and a conversation visibly
shifted the bar's height, insets and type.

Introduces ConversationHeader, built from the main header's own tokens rather
than TopAppBar: same ChatHeaderHeight, same 12/8dp edge insets, leading glyph
in a 44dp slot so it lands exactly where the brand mark does, same -6dp
optical nudge pulling the title toward it, same 17sp label.

- Drops the back button; the close action on the right is the way out.
  Leaving a channel outright already lives on its row in the network sheet,
  so it does not need a second home beside the exit.
- Leading glyph is the transport: globe over the internet, wifi/bluetooth/
  routed on the mesh, matching the main header's channel button.
- Actions are right-aligned and unweighted -- favourite, encryption state,
  close -- so a long title yields space to them instead of pushing them off
  screen.
- Private chat titles use the primary green like every other header label,
  rather than orange for Nostr-reachable peers.

Height and edge insets now belong to each header variant instead of the
ChatFloatingHeader wrapper, which was applying them a second time to the
channel header.

Adds nine spec icons in the existing 20x20 / 1.25-stroke language -- bluetooth,
wifi, routed, close, check, warning, sync, lock_open, envelope -- so the
headers and peer rows no longer mix Material glyphs into the set.

* color
2026-07-27 17:33:11 +02:00
callebtc
3054cd801e Centralize background power scheduling 2026-07-27 17:27:40 +02:00
callebtc
adba24b5de
test: add client rewrite contract suite (#779) 2026-07-27 17:05:43 +02:00
callebtc
f27c47dae2
fix: enforce soft live-location privacy gate (#780)
* fix: enforce soft location privacy gate

* fix: address location privacy review
2026-07-27 16:08:50 +02:00
callebtc
25e9779f73 colors 2026-07-27 16:06:22 +02:00
callebtc
c1c7704c32 grouping of geohash channel list 2026-07-27 15:46:11 +02:00
callebtc
b1d709fc4a fix mentions 2026-07-27 15:32:33 +02:00
callebtc
4863381dbb mentions 2026-07-27 15:27:15 +02:00
callebtc
dabc520090 Merge branch 'main' into opus/redesign-proposal 2026-07-27 14:27:27 +02:00
callebtc
fe4920465d cleanup 2026-07-27 14:02:24 +02:00
callebtc
34175988a5 icons 2026-07-27 13:49:18 +02:00
callebtc
352dd6e93f new icons 2026-07-27 13:45:51 +02:00
callebtc
7cef3f329e timestamp no seconds 2026-07-27 13:28:35 +02:00
callebtc
a85ba40637 geist mono 2026-07-27 13:27:15 +02:00
callebtc
7b03863eaa remove empty lable 2026-07-27 13:10:51 +02:00
callebtc
8db6ccfdc3 fix location channel lifecycle bug 2026-07-27 13:06:00 +02:00
callebtc
703fcd0e02 move location error 2026-07-27 13:02:11 +02:00
callebtc
a277b5e753 location sheet 2026-07-27 12:57:03 +02:00
callebtc
7ad709c38e icon 2026-07-27 12:51:34 +02:00
callebtc
562aa787d6 fix location channel layout 2026-07-27 12:49:57 +02:00
callebtc
fa43821cf4 Key message list state per conversation
Switching channels reused every piece of state in MessagesList, because none
of it was keyed on which conversation was being shown:

- The LazyListState carried the previous channel's scroll offset, so the new
  channel opened at a stale position and then corrected itself.
- hasScrolledToInitialPosition and followIncomingMessages carried over, so a
  channel entered after scrolling up in another one did not land on its
  newest message at all.
- The arrival tracker had never seen the incoming channel's ids, so a
  backlog of six or fewer messages was treated as six simultaneous arrivals
  and each one slid in.
- previousMessageCount carried over, arming placement animation for the
  relayout that the switch itself caused.

All of it is now keyed on a conversationKey derived the same way
displayMessages is. The tracker also detects a list sharing no ids with the
previous one and adopts it silently, which covers /clear and any caller that
does not supply a distinct key.

Adds 4 tests for wholesale replacement, including the case that the burst
cap cannot catch on its own.
2026-07-27 12:36:09 +02:00
callebtc
bc49c71ea0
security: stop logging Noise key material, reduce noisy logging app-wide (#775)
C1 from security review: SymmetricState/HandshakeState logged raw X25519
shared secrets, chaining keys, and handshake hashes in hex to logcat on
every handshake, in release builds. A logcat transcript of a handshake
allowed full session decryption. Both classes no longer log at all.

Also reduces excessive logging across the app (~50% fewer log calls in
the noisiest files):

- NoiseSession emits one line per completed handshake; per-message
  encrypt/decrypt and per-handshake-step debug logs removed
- Removes all content/key logging: decrypted DM content, file names,
  payload hex dumps, pubkeys, event IDs, lat/lon, peer IPs, arti log
  forwarding
- Collapses multi-line banner/emoji log sequences into single factual
  lifecycle lines (connect/disconnect, relay/Tor state transitions)
- Keeps security-relevant warnings (signature failures, replay
  detection, key mismatches, panic wipe) in compact form

No logic changes. Includes the full security review report in
docs/security-review-jul-27.md.
2026-07-27 10:33:29 +02:00
callebtc
bcfc33f35c Group geohash people as People and Anon
Replaces the "on location" / "teleported in" / "anonymous" split with two
sections: peers who announced a nickname, then the anons.

Teleport state was never worth a section of its own -- every row already
carries it as a distinct glyph -- and splitting on it fragmented the short
list people actually read, in a channel where most participants are
anonymous anyway.

Self stays in the People section even when unnamed.
2026-07-27 10:33:29 +02:00
callebtc
9703ebfcaf Fix layout jumpiness in chat and geohash people list
Three separate causes of things moving when they should not:

- Chat lurched whenever a bottom sheet closed. Placement animation is meant
  to soften insertions and removals, but any relayout moves every item --
  a sheet's text field opening the keyboard changes the chat's IME inset,
  and closing it changes it back. Placement animation is now armed only
  briefly around a real change to the message list, so items otherwise
  track the viewport exactly.

- Anon list changed height as participants churned. Rows sized to their
  content, so any reorder could change the card's height; and the card
  sized to the live anon count, which moves constantly in a busy geohash.
  Rows now have an exact height, and a trimmed anon card reserves the full
  capped height regardless of how many are present beyond the cap.

- Anons are now their own trailing section rather than a tail on each of
  "on location" and "teleported in", which had pushed the few recognisable
  names out of view twice over. Self is never grouped as an anon.

Adds 7 tests covering the sectioning and the fixed-length behaviour.
2026-07-27 10:09:37 +02:00
callebtc
0cb28a4f3a Merge remote-tracking branch 'origin/main' into opus/redesign-proposal
# Conflicts:
#	app/src/main/java/com/bitchat/android/ui/AboutSheet.kt
#	app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
#	app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt
#	app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt
2026-07-27 04:53:45 +02:00
callebtc
5428bc9a03 animations 2026-07-27 04:22:16 +02:00
callebtc
8e849a7bbe fixes 2026-07-27 04:14:54 +02:00
callebtc
c0b1af3d0f button 2026-07-27 03:59:51 +02:00
callebtc
32d5e6f1dc cleanup 2026-07-27 03:51:25 +02:00
callebtc
3dbe9c1c52 lists 2026-07-27 03:50:46 +02:00
callebtc
521784b359 nicer 2026-07-27 03:44:13 +02:00
callebtc
33b060b551 nice 2026-07-27 03:37:55 +02:00
callebtc
974de992f0 notes 2026-07-27 03:29:09 +02:00
callebtc
02a737fad6 fixes 2026-07-27 03:26:15 +02:00
callebtc
893e7d3875 input bar fixes 2026-07-27 03:11:11 +02:00
callebtc
53a891a71f strings 2026-07-27 02:58:06 +02:00
callebtc
fc1c23314b capitalization 2026-07-27 02:57:22 +02:00
callebtc
15d56715b9 cleanup 2026-07-27 02:51:19 +02:00
Moe Hamade
4a34408db9
Enhancement/apk sharing (#632)
* feat: Add QR code generator for Wi-Fi and URLs

This commit introduces a `QrCodeGenerator` utility object to create QR code bitmaps for both Wi-Fi credentials and URLs.

Key features:
- **`generateWifiQr`**: Creates a QR code using the standard `WIFI:` format, allowing other devices to connect to a hotspot by scanning the code. It properly escapes special characters in the SSID and password.
- **`generateUrlQr`**: Generates a standard QR code for any given URL.
- **Implementation**: Uses the `zxing` library to encode the data and converts the resulting `BitMatrix` into an Android `Bitmap`.

* feat: Add manager for universal APK sharing

This commit introduces a comprehensive system for fetching, downloading, caching, and managing a "universal" APK of the app, intended for offline sharing with new users.

The core components are:
- `GitHubReleaseClient`: A new client to fetch the latest release information from the project's GitHub repository. It specifically looks for a universal APK asset in the release, parses its download URL, and attempts to extract its SHA256 checksum from the release notes.
- `UniversalApkManager`: Manages the entire lifecycle of the universal APK. It handles:
    - Checking for new versions by comparing the cached APK version against the latest GitHub release.
    - Downloading the APK with progress reporting.
    - Verifying the downloaded file against the SHA256 checksum, if available.
    - Caching the APK and its metadata (version, checksum, size) locally.
    - Cleaning up old APK versions to conserve space.

* feat: Add offline APK sharing via Wi-Fi hotspot

This commit introduces a comprehensive feature for sharing the BitChat application offline using a self-hosted Wi-Fi Direct hotspot. This enables mesh network expansion by allowing users to distribute the app without requiring an internet connection.

Key components:
- **`HotspotManager`**: A new class that manages the creation and lifecycle of a Wi-Fi P2P (Wi-Fi Direct) group. It handles generating secure credentials (SSID/password), acquiring WakeLocks, and monitoring connected peers. It supports custom credentials on Android 10+ and falls back to system-generated ones on older versions.
- **`ApkWebServer`**: A lightweight HTTP server based on `NanoHTTPD` that serves the APK file and a user-friendly HTML landing page to connected devices.
- **`ApkSharingUtils`**: A utility to detect whether the app is installed as a single or split APK, collect the necessary files, and copy them to a cache directory for sharing.
- **`ApkInstaller`**: A utility using the `PackageInstaller` API to handle the installation of single or split APKs received from another user.
- **`HotspotActivity`**: A new Compose-based UI that guides the user through starting the hotspot, displays connection details (Wi-Fi credentials, QR codes for Wi-Fi and the download URL), and shows the number of connected peers. It also handles the necessary runtime permissions (`NEARBY_WIFI_DEVICES` or `ACCESS_FINE_LOCATION`).
- **Configuration**:
    - Adds necessary Wi-Fi and P2P permissions to `AndroidManifest.xml`.
    - Defines a `FileProvider` path for APK sharing in `file_paths.xml`.
    - Adds numerous string resources for the new UI.

* feat: Add offline and online app sharing features

This commit introduces a comprehensive feature set for sharing the application, both offline via a Wi-Fi hotspot and online through standard Android sharing mechanisms.

Key additions:

- **Prepare for Sharing UI:**
    - Adds a "Prepare App for Sharing" option in the settings sheet.
    - This feature downloads a universal APK from a remote source, suitable for all Android devices.
    - The UI displays the status: not downloaded, downloading (with progress), ready, or if an update is available.
    - Users can download, update, or delete the cached universal APK.

- **Offline Sharing via Wi-Fi Hotspot:**
    - Adds a "Share via Wi-Fi Hotspot" option.
    - This launches a new `HotspotActivity` to share the prepared universal APK with nearby devices without an internet connection.
    - A dialog informs the user if the APK hasn't been prepared yet.

- **Online & Local Sharing:**
    - Adds an option to share via Bluetooth, email, etc., using the standard Android share sheet.
    - This method shares the *installed* version of the app, which may be a split APK.
    - An explanatory dialog is shown first, instructing the receiver on how to install split APKs if necessary.
    - Implements logic to correctly package and share single or multiple split APK files using `FileProvider`.

* feat: Add NanoHTTPD for hotspot APK sharing

This commit introduces the `nanohttpd` library, which will be used to implement an HTTP server for sharing the application's APK over a local hotspot.

The specific dependency added is `org.nanohttpd:nanohttpd:2.3.1`.

* refactor: Improve hotspot and APK sharing stability

This commit introduces several fixes and refinements to the hotspot sharing and APK handling features, improving stability, user experience, and robustness.

Key changes:

-   **Hotspot Flow:**
    -   Automatically starts the hotspot after the user grants the required Wi-Fi permission, removing the need for a second button press.
    -   Ensures all `HotspotManager` callbacks in `HotspotViewModel` are executed within `viewModelScope` to prevent threading issues and ensure safe UI updates.
    -   Fixes a potential `BroadcastReceiver` leak in `HotspotManager` by tracking its registration state, preventing crashes and resource leaks when stopping the hotspot.
    -   Changes the hotspot `WakeLock` to be non-expiring to prevent the CPU from sleeping while the hotspot is active.

-   **APK Handling & Installation:**
    -   Adds a pre-download disk space check in `UniversalApkManager` to prevent download failures on devices with insufficient storage.
    -   Improves the file move logic after download by falling back to a copy-and-delete strategy if a direct rename fails, making it more robust across different filesystems.
    -   Introduces `InstallResultReceiver` to provide clear Toast notifications to the user about the success or failure of an APK installation, including specific error reasons (e.g., "Not enough storage").

-   **Performance & UI:**
    -   Caches the generated HTML in `ApkWebServer` to improve performance by avoiding regeneration on every request.
    -   Throttles the APK download progress updates to prevent UI jankiness from too-frequent state changes.
    -   Moves hardcoded strings in the "Share App" UI to `strings.xml` for better localization and maintenance.

* feat: Refactor APK sharing to use universal APK

This commit refactors the "Share App" functionality to exclusively use the new universal APK system, removing the previous logic that shared the installed split APKs. This simplifies the sharing process and ensures a consistent, single-file sharing experience for all users.

Key changes:
- Deletes `ApkSharingUtils.kt`, which was responsible for detecting and copying split APKs from the device's installation directory.
- Updates `AboutSheet.kt` to use `UniversalApkManager` for all sharing actions (Hotspot and "Quick Share").
- Simplifies the sharing intent logic, as it now only needs to handle a single APK file (`ACTION_SEND`) instead of multiple files (`ACTION_SEND_MULTIPLE`).
- The UI for sharing options (Hotspot, Quick Share) is now dynamically hidden until the universal APK is prepared, preventing user confusion.
- Replaces hardcoded strings with string resources for better localization.

* refactor: Remove InstallResultReceiver

Deletes the `InstallResultReceiver` broadcast receiver.

This component was responsible for handling the results of an APK installation initiated via `PackageInstaller`, but it is no longer used in the current implementation.

* feat: Move APK download to resumable WorkManager pipeline

Replaces the ViewModel-scoped coroutine download with a WorkManager-backed
downloader so downloads survive app backgrounding and process death:

- New ApkDownloader interface with WorkManagerApkDownloader implementation
  and ApkDownloadWorker (CoroutineWorker); transient IO errors return
  Result.retry() and resume via HTTP Range requests from the partial file.
- UniversalApkManager gains resume support (Range header + persisted resume
  metadata) and verifies the downloaded APK is signed with the same
  certificate as the running app (no hardcoded fingerprint; debug-signed
  builds skip enforcement).
- APK downloads now go through the shared OkHttpProvider so they respect
  the app's Tor proxy configuration instead of leaking the direct IP.
- AboutSheet logic extracted into ApkDownloadViewModel (MVI: state/event/
  effect), removing ~240 lines of UI-embedded logic.
- Removes unused ApkInstaller (receivers install via the system installer).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: Harden hotspot server and release metadata fetching

- ApkWebServer only serves the exact /bitchat.apk path instead of any
  *.apk-suffixed URI.
- GitHubReleaseClient uses the shared OkHttpProvider (respects Tor proxy)
  and drops the loose 'any lone 64-hex string in the release notes is the
  checksum' fallback, which was spoofable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: Address review findings in HotspotManager

- Keep polling for group info while the group is still forming instead of
  silently stopping when the first requestGroupInfo() returns null, with a
  15s formation timeout (Codex P1).
- Release wake/wifi locks and unregister the broadcast receiver on terminal
  startup failures via failStartup(), so a failed attempt no longer leaks
  resources or blocks subsequent attempts (Codex P2).
- Use PARTIAL_WAKE_LOCK with a 30-minute timeout instead of the deprecated
  FULL_WAKE_LOCK held indefinitely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: harden offline APK sharing

* fix: Handle fully-downloaded temp file before adding Range header

If the process died after download_temp.apk was fully written but before
verification/promotion, the next attempt sent "Range: bytes=<size>-",
GitHub answered 416, and the worker retried the same request forever,
leaving the user stuck on an unresumable download.

- Skip the network entirely when the temp file already holds the full
  asset and go straight to checksum/signature verification.
- Treat an HTTP 416 response as an invalid resume offset: discard the
  partial state so the retry restarts from scratch instead of looping.

Addresses the Codex review finding on UniversalApkManager.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: Allow sharing when the GitHub release lags the installed version

Upstream bumps versionName in main before tagging the GitHub release, so
there is a recurring window where the installed app is newer than the
latest published universal APK. The hard version guard disabled the whole
sharing feature during that window (including for reviewers building this
branch at 1.7.5 while GitHub's latest is 1.7.4).

An older release is still a genuine, signed, checksum-verified universal
artifact, and Android already refuses downgrade installs on receivers, so:

- checkForUpdate now logs (instead of erroring) when the latest release is
  older than the installed app and proceeds normally.
- downloadUniversalApk no longer fails for an older-than-installed release.
- A cached artifact stays shareable regardless of the installed version.

The cached-artifact preference (never replace a newer cached APK with an
older one) and all signature/checksum verification are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: Run APK download as foreground (dataSync) work

Long transfers — especially over Tor — can exceed WorkManager's
~10-minute background execution window, getting the worker stopped and
rescheduled repeatedly. Promote the download to foreground work with a
progress notification (cancel action included) so it can run to
completion.

- setForeground() with FOREGROUND_SERVICE_TYPE_DATA_SYNC; the manifest
  already holds the FOREGROUND_SERVICE_DATA_SYNC permission, and the
  WorkManager SystemForegroundService is merged with type dataSync.
- If Android 12+ rejects the promotion (app backgrounded), the worker
  logs and continues as regular background work, relying on Range-resume.
- Notification updates are throttled to 5% steps and degrade gracefully
  without POST_NOTIFICATIONS.

Addresses the Codex review finding on ApkDownloadWorker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: Move temp APK into place instead of copying it

replaceFileSafely copied the source into a .new candidate before the
atomic move, doubling peak disk usage: with free space between 1.5x and
2x the APK size, the download completed and then promotion failed on the
copy, retrying against the same full temp file.

Source and target always live in the same cache directory, so a direct
ATOMIC_MOVE (rename) needs no extra space and keeps the same guarantee:
it either fully succeeds or leaves both files intact. The existing 1.5x
margin in checkDiskSpace is now genuinely sufficient.

Addresses the Codex review finding on UniversalApkManager.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: Base the disk space check on remaining bytes when resuming

The check ran before resume state was read and always demanded 1.5x the
full APK size. Bytes already sitting in download_temp.apk have already
consumed storage, so on a low-storage device an interrupted download
could fail every resume with "Insufficient storage" even when only a
small tail was left to fetch.

Read the resume state first and check space for the remaining bytes
only. A fresh download still checks the full size, and a complete temp
file needs no extra space since promotion is a rename.

Addresses the Codex review finding on UniversalApkManager.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: cancel APK downloads promptly

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2026-07-27 02:45:07 +02:00
callebtc
d615fc9cc0
Gate nearby notes behind tap-to-reveal consent (#771)
* Add nearby notes tap-to-reveal consent

* Stop nearby notes while app is backgrounded
2026-07-27 02:17:44 +02:00
callebtc
ea0660e717 pass 2 2026-07-27 02:12:19 +02:00
callebtc
e1d07f99f5 first pass 2026-07-27 01:56:59 +02:00
Ruslan
92d07b22fa
ui: refresh chat branding and text message layout (#767)
* Add BitChatBrandButton and BitChatIcon

Add a dedicated brand button component and custom vector icon to the UI library. Refactor the chat header to use this component, replacing the previous modifier-based multi-click implementation.

* **BitChatBrandButton**: A new Composable that encapsulates single and triple click detection logic using coroutine delays and tap counting.
* **BitChatIcon**: A custom pixel-style `ImageVector` representing the brand.
* **ChatHeader**: Updated to use the new brand button and included a visual separator (`/`) in the layout.
* **ModifierExt.kt**: Removed the `singleOrTripleClickable` extension as its functionality is now handled internally by the brand button component.

* ui: refactor text messages to two-row layout

Refactor the message list items to use a two-row layout for standard text messages, while preserving the legacy compact format for system messages.

*   **UI Formatting**: Added `formatTextMessageSender`, `formatTextMessageMetadata`, and `formatTextMessageBody` in `ChatUIUtils.kt` to separate the rendering of sender info, timestamps/PoW, and message content.
*   **Layout Change**: Replaced the single-block `Text` component with a `Column` containing a `Row` (Sender + Metadata) and a message body `Text` block for standard messages.
*   **Interaction**: Updated `pointerInput` and `detectTapGestures` to handle nickname clicks in the header row and geohash/URL clicks within the message body row.
*   **Styling**: Refined `appendIOSFormattedContent` to support a `contentColor` parameter and applied specific font weights and colors (e.g., orange for self-messages) to match the platform's design language.
*   **System Messages**: Explicitly kept messages where `sender == "system"` on the original compact layout.

* refactor(ui): extract annotated text pointer handling

* fix(ui): address chat redesign review feedback

* fix(ui): align PoW animation with text layout

* refactor(ui): extract message interaction helpers

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2026-07-27 00:54:51 +02:00
Moe Hamade
61588db474
chore(deps): upgrade to AGP 9.3.1, Gradle 9.6.1, Kotlin 2.4.10, SDK 37 (#750)
* chore(deps): upgrade to AGP 9.3.1, Gradle 9.6.1, Kotlin 2.4.10, SDK 37

Bring the toolchain and every dependency to latest stable. No app source
changes were required.

Toolchain:
- AGP 8.10.1 -> 9.3.1, Gradle 8.13 -> 9.6.1, Kotlin 2.2.0 -> 2.4.10
- compileSdk 35 -> 37, targetSdk 35 -> 37 (Android 17, stable)
- Java 8 -> 11

AGP 9 migration (built-in Kotlin):
- Drop org.jetbrains.kotlin.android; AGP 9 provides Kotlin natively and the
  plugin is incompatible with the new DSL
- Migrate kotlinOptions.jvmTarget to kotlin.compilerOptions (the String
  setter is a hard error in Kotlin 2.4)
- Drop android.enableJetifier (deprecated, removed in AGP 10, no support
  library deps remain)

Libraries:
- Compose BOM 2025.06.01 -> 2026.06.01, activity-compose 1.10.1 -> 1.13.0
- core-ktx 1.16.0 -> 1.19.0, lifecycle 2.9.1 -> 2.11.0 (unified with
  lifecycle-process, which had drifted to 2.8.7)
- okhttp 4.12.0 -> 5.4.0, coroutines 1.10.2 -> 1.11.0, gson 2.13.1 -> 2.14.0
- BouncyCastle 1.70 -> 1.85, switching bcprov-jdk15on -> bcprov-jdk18on
  (jdk15on is abandoned; same org.bouncycastle packages)
- Tink 1.10.0 -> 1.23.0, CameraX 1.5.2 -> 1.6.1, gms-location 21.3.0 -> 21.4.0
- security-crypto 1.1.0-beta01 -> 1.1.0, navigation-compose 2.9.1 -> 2.9.8
- exifinterface 1.3.7 -> 1.4.2, moved from a hardcoded coordinate into the
  version catalog
- Tests: espresso 3.6.1 -> 3.7.0, test-ext 1.2.1 -> 1.3.0, mockito-kotlin
  4.1.0 -> 6.3.0; mockito-inline (deprecated) -> mockito-core 5.23.0;
  coroutines-test 1.6 -> 1.11.0, now sharing the coroutines version ref
  instead of drifting

Robolectric stays pinned at 4.15: 4.16+ breaks EncryptionServiceTest with
"AndroidKeyStore not found". Bisected away from security-crypto and shown not
to be SDK-level related. Unpinning needs an EncryptionService refactor, which
is deliberately left to a follow-up PR.

targetSdk behaviour changes for API 36 and 37 were audited against the source:
edge-to-edge and predictive back are already handled, ACCESS_LOCAL_NETWORK is
not needed (loopback only, for Arti's SOCKS proxy), the reflection in
ChatViewModel touches instance rather than static final fields, and there is
no RFCOMM or scheduleAtFixedRate usage.

Verified: compileDebugKotlin, testDebugUnitTest (96 tests, 0 failures),
bundleRelease with R8, gradlew help, and build --dry-run. The 6 R8 "cannot
parse kotlin metadata" warnings present under AGP 8.13.2 are gone under 9.3.1.

Not verified on hardware. BLE mesh, foreground services, Nostr relay
websockets, Tor, and the Noise handshake still need a device smoke test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: declare ACCESS_LOCAL_NETWORK for Wi-Fi Aware on Android 17

Android 17 (API 37) makes local network protection mandatory for apps
targeting it. WifiAwareMeshService reaches peers over link-local IPv6 TCP
sockets (connectAwareClientSocket), which may be gated by the new
ACCESS_LOCAL_NETWORK runtime permission once targetSdk is raised to 37.

The official local network permission documentation frames the feature as
LAN access and does not explicitly state whether Wi-Fi Aware peer-to-peer
networks are in scope, so this is defensive rather than confirmed-necessary.
The sockets are bound to a dedicated Aware Network obtained via
requestNetwork, not the user's subnet.

Declaring it costs nothing: ACCESS_LOCAL_NETWORK shares the NEARBY_DEVICES
group with NEARBY_WIFI_DEVICES, so users who have already granted the latter
are not prompted again. The runtime request is gated on SDK_INT >= 37 so
older devices are unaffected.

Raised by automated review on #750.

Verified: compileDebugKotlin, testDebugUnitTest (124 tests, 0 failures),
bundleRelease, and ACCESS_LOCAL_NETWORK present in the merged manifest.
Not verified on an Android 17 device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct ACCESS_LOCAL_NETWORK permission-group claim

Device testing on Android 17 (API 37) disproved the earlier claim that
ACCESS_LOCAL_NETWORK is effectively free because it shares the NEARBY_DEVICES
group with NEARBY_WIFI_DEVICES.

Granting NEARBY_WIFI_DEVICES alone leaves ACCESS_LOCAL_NETWORK denied:

  pm grant ... NEARBY_WIFI_DEVICES
  -> NEARBY_WIFI_DEVICES:  granted=true
  -> ACCESS_LOCAL_NETWORK: granted=false

The two are tracked and granted independently, so ACCESS_LOCAL_NETWORK has to
be requested explicitly. That is exactly what the wifiAwarePermissions() list
already does, so no behavioural change is needed — only the comments were
wrong. Whether the runtime dialog bundles the two into a single prompt remains
unverified, since enabling Wi-Fi Aware from Debug Settings after onboarding
never triggers a permission request at all (pre-existing, unrelated to this
branch).

Comment-only change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: request Wi-Fi Aware permissions when enabling it from Debug Settings

Enabling Wi-Fi Aware from the Debug Settings sheet never requested the
permissions it needs. The permission flow is reachable only through
PermissionManager.getRequiredPermissions(), which gates the Wi-Fi Aware entries
behind shouldRequireWifiAwarePermission() — and that returns false unless the
debug toggle is already on. Since the toggle defaults to off, onboarding never
asks, and flipping it later starts WifiAwareController directly, which only
checks the permission and bails.

The result was a silent dead end: Wi-Fi Aware could never start, and the
controller logged "Missing NEARBY_WIFI_DEVICES permission" on a 5s retry loop
indefinitely. Reproduced on a Pixel 9a (Android 17) and a Samsung SM-A366E
(Android 16); both needed adb grants to get the transport running at all.

The toggle and the Start chip now request the permissions first and only enable
the transport once NEARBY_WIFI_DEVICES is granted. ACCESS_LOCAL_NETWORK is
treated as best-effort since it does not exist below API 37 — confirmed by
`pm grant` rejecting it as an unknown permission on the Android 16 device. The
list comes from PermissionManager.wifiAwarePermissions() so the API 37 gate has
a single definition.

Also corrects the permission-group comments now that both levels are verified
on Android 17: grants are tracked independently (granting NEARBY_WIFI_DEVICES
alone leaves ACCESS_LOCAL_NETWORK denied), but the two share the NEARBY_DEVICES
group so requesting them together produces a single "Nearby devices" prompt.

Verified on device: after a clean uninstall/reinstall, toggling Wi-Fi Aware
produced one prompt and left both permissions granted with the USER_SET flag.

Addresses the second automated review finding on #750.

Verified: compileDebugKotlin, testDebugUnitTest (124 tests, 0 failures).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: version-gate Wi-Fi Aware permissions, check live grant state

Two issues from automated review of ee1ee3ad.

wifiAwarePermissions() returned NEARBY_WIFI_DEVICES unconditionally, but that
permission only exists from API 33 while minSdk is 26 and Wi-Fi Aware is
available from API 26. On an API 26-32 device the new enable path would request
an unknown permission, receive a denial, and never enable a transport that
needs no runtime permission there at all — a regression introduced by the
previous commit. Both entries are now version-gated.

The result callback also inferred the Nearby grant from the result map, which
omits permissions that were already held and so filtered out before launching.
It now reads the live permission state instead.

A denied ACCESS_LOCAL_NETWORK still does not block enabling: the controller
starts fine without it (verified on Android 17), and its necessity for
link-local sockets remains unproven, so a denial should not disable a transport
that otherwise works.

Also trims the comments added in the last two commits down to the density of
the surrounding code.

Verified: compileDebugKotlin, testDebugUnitTest (124 tests, 0 failures).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:34:08 +02:00
jack
84b24e347f
Bind Noise sessions to claimed peer identities (#730)
* Bind Noise sessions to claimed peer identities

* Make Wi-Fi peer rebinding atomic

* Migrate private media without silent downgrades (#728)

* Migrate private media without silent downgrades

* Accept prerelease private media payloads

* Authenticate private media capability per Noise session

* updates

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jack@deck.local>
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>

* Authenticate BLE links before peer binding (#749)

* Authenticate BLE links before peer binding

* Fix BLE link authentication races

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>

* Use canonical Maven repository for Robolectric

---------

Co-authored-by: jack <jack@deck.local>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
Co-authored-by: a1denvalu3 <43107113+a1denvalu3@users.noreply.github.com>
2026-07-26 23:29:57 +02:00
callebtc
5a38aaf3e8
feat: stabilize private chat identity across mesh and Nostr (#745)
* feat: stabilize private chat identity across mesh and Nostr

Introduce ContactDirectory and ContactIdentityResolver so DMs and favorites
use stable contact_* conversation IDs instead of ephemeral mesh peer IDs.

* fix: preserve canonical private message routing

* test: complete private chat regression fixtures
2026-07-26 13:06:22 +02:00
aharshit123456
6cd5d1687e i18n: backfill 7 severely incomplete locale translations
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>
2026-07-26 14:39:53 +05:30
GitHub Action
c8f45408b1 Automated update of relay data - Sun Jul 26 06:56:54 UTC 2026 2026-07-26 06:56:54 +00:00
callebtc
b7f0b33d3a
bump to 1.7.5 (#723) 2026-06-25 22:48:26 +02:00
Hamza Öztürk
c26460ae02
feat: Implement iOS-compatible selective padding for BLE messages (#501)
* feat: Implement iOS-compatible selective padding for BLE messages

* regression tests for padding

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2026-06-25 22:45:03 +02:00
Moe Hamade
793b215457
fix: geohash online count accuracy and channel switch latency (#672)
- Keep max lastSeen per user so relay's newest-first delivery doesn't
  overwrite a fresh timestamp with a stale one
- Serialize event processing on Main dispatcher to eliminate
  ConcurrentModificationException on geohashParticipants maps
- Immediately refresh people list on channel switch so sampling data
  is reflected before the first relay response arrives
2026-06-25 22:07:59 +02:00
Moe Hamade
f0d312827e
feat: Disable system screenshots in recents screen (#608)
This change prevents the app's content from being visible in the recents screen on Android 13 (Tiramisu) and newer devices.

It achieves this by calling `setRecentsScreenshotEnabled(false)` in the `onCreate` method of `MainActivity` after checking the device's SDK version. This enhances user privacy by hiding potentially sensitive information from the system's app overview.
2026-06-25 22:07:09 +02:00
a1denvalu3
8607ce84ba
Fix geohash block case sensitivity issue (#645)
Co-authored-by: a1denvalu3 <>
2026-06-25 22:06:46 +02:00
AleksPlekhov
37a20ebf34
[ticket 640] added nickname to note (#687)
* [ticket 640] added nickname to note

* [640] added check for empty value
2026-06-25 22:06:05 +02:00
5t34k
34a6f37041
Fix hashtags incorrectly displayed inside URLs (#692)
When a URL contains a # fragment (e.g. https://example.com/page#section),
the hashtag regex matches the fragment as a hashtag. The overlap removal
logic already removes hashtags overlapping geohashes, and geohashes
overlapping URLs, but did not remove hashtags overlapping URLs. Add the
missing overlapsUrl check to the hashtag condition.
2026-06-25 22:04:23 +02:00
bladexian94-code
648ae6c1dd
Harden GeohashPickerActivity WebView security by blocking mixed content and restricting navigation to local asset only on experimental security branch. (#698) 2026-06-25 21:57:17 +02:00
David Carrington
13c771aa29
Pause Nostr geohash firehose in background to cut mobile data Closes: #700 (#706)
* Pause Nostr geohash firehose in background to cut mobile data

The Bluetooth mesh uses no mobile data, but the Nostr relay layer ran
unbounded in the background: the live geohash channel subscription kept
streaming across 5 relays, the presence heartbeat broadcast every ~60s,
and a participant-refresh timer polled every 30s. Combined with
reconnect/re-subscribe replay on public relays, this could burn gigabytes
while idle.

GeohashViewModel now tears down the high-volume subscriptions when the app
is backgrounded (onStop) and restores them on foreground (onStart):
- drop the live channel message stream (currentGeohashSubId)
- drop sampling subscriptions
- cancel the presence heartbeat and participant-refresh timer

Gift-wrap DM subscriptions are intentionally kept alive in the background
since they are filtered to our pubkey (lightweight) so DMs still arrive.

The selected channel is tracked in activeChannelGeohash so the stream can
be rebuilt on foreground without losing the user's selection. The channel
subscription logic is extracted into subscribeChannelStream() /
subscribeChannelDM() helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* separate the heartbeat firehose from message delivery

* correct comment

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2026-06-25 21:56:29 +02:00
GitHub Action
ff15bddfc8 Automated update of relay data - Sun Jun 21 07:41:57 UTC 2026 2026-06-21 07:41:57 +00:00
callebtc
ffe62bf1fd
bump to 1.7.4 (#715)
Co-authored-by: CC <cc@ggg.local>
2026-06-17 12:27:54 -05:00
callebtc
b89644f5f7
Fix foreground service start eligibility (#714)
* Fix foreground service start eligibility

* Defer foreground service start when backgrounded

* fix crash

---------

Co-authored-by: CC <cc@ggg.local>
2026-06-17 11:59:43 -05:00
callebtc
9e0a919cb8
bump version (#713)
Co-authored-by: CC <cc@ggg.local>
2026-06-16 12:49:36 -05:00
callebtc
ae6641270a
feat: disable Wi‑Fi Aware by default (#712)
Co-authored-by: CC <cc@ggg.local>
2026-06-16 12:45:23 -05:00
callebtc
3a983c5767
Wifi aware refactor mesh core (#711)
* wifi aware wip

* wifi aware wip

* starting to work

* werk

* dms work

* wip

* fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork

* Merge branch 'upstream/main' into fix/wifi-aware-socket-binding

* Fix Wi-Fi Aware connectivity and UI integration post-merge

- Replace bindProcessToNetwork with bindSocket for VPN compatibility.
- Implement Scoped IPv6 address resolution (aware0) for mesh routing.
- Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility.
- Fix syntax errors and variable name conflicts in Debug UI.

* Enhance Wi-Fi Aware robustness and debug UI display

- Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection.
- Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI.
- Fix Map type mismatch warning in ChatViewModel bridge.
- Filter self-ID from peer cleanup tables to prevent recursive self-removal.

* Share GossipSyncManager across transports to prevent redundant message synchronization

- Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder.
- Modified WifiAwareMeshService to use the shared GossipSyncManager if available.
- Added background cleanup for peer mappings on socket disconnection.
- Fixed Kotlin type mismatch during nickname map merging.

* Restore VPN acquisition logic and improve peer cleanup

- Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active.
- Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs.
- Switch cleanup logging to debug level to reduce log noise.

* Fix wifi aware socket binding 2 (#536)

* Background persistence (#505)

* persistence step 1

* fix build

* messages in the background work, notifications not yet

* app state store

* DM icon shows up

* notification launches when app is closed!

* keep ui updated

* lifecycle fixes

* extensive logging, maybe revert later

* send nickname in announcement

* quit in notification

* setting in about sheet

* fix quit bitchat

* lifecycle fixes

* power mode based on background state

* stats for both direciotns

* fix graph persistence

* better counting

* count per device

* only compute when debug sheet is open? untested

* fix read receipts

* fix read receipts fully

* fix unread badge if messages have been read in focus

* foreground promotion fix

* fix app kill in notification

* adjust to new tor

* nice

* about sheet design

* bump version 1.6.0 (#524)

* Automated update of relay data - Sun Dec 14 06:06:53 UTC 2025

* bump targetSdk (#526)

* Automated update of relay data - Sun Dec 21 06:06:56 UTC 2025

* Automated update of relay data - Sun Dec 28 06:07:12 UTC 2025

* Prevent quit notification from reappearing (#530)

* shutdown sequence

* Prevent quit notification from reappearing

* Restrict force-finish broadcast

* Cancel quit shutdown on relaunch

* fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork

* Merge branch 'upstream/main' into fix/wifi-aware-socket-binding

* Fix Wi-Fi Aware connectivity and UI integration post-merge

- Replace bindProcessToNetwork with bindSocket for VPN compatibility.
- Implement Scoped IPv6 address resolution (aware0) for mesh routing.
- Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility.
- Fix syntax errors and variable name conflicts in Debug UI.

* Enhance Wi-Fi Aware robustness and debug UI display

- Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection.
- Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI.
- Fix Map type mismatch warning in ChatViewModel bridge.
- Filter self-ID from peer cleanup tables to prevent recursive self-removal.

* Share GossipSyncManager across transports to prevent redundant message synchronization

- Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder.
- Modified WifiAwareMeshService to use the shared GossipSyncManager if available.
- Added background cleanup for peer mappings on socket disconnection.
- Fixed Kotlin type mismatch during nickname map merging.

* Restore VPN acquisition logic and improve peer cleanup

- Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active.
- Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs.
- Switch cleanup logging to debug level to reduce log noise.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: aidenvalue <>

* Fix wifi aware routing (#534)

* Background persistence (#505)

* persistence step 1

* fix build

* messages in the background work, notifications not yet

* app state store

* DM icon shows up

* notification launches when app is closed!

* keep ui updated

* lifecycle fixes

* extensive logging, maybe revert later

* send nickname in announcement

* quit in notification

* setting in about sheet

* fix quit bitchat

* lifecycle fixes

* power mode based on background state

* stats for both direciotns

* fix graph persistence

* better counting

* count per device

* only compute when debug sheet is open? untested

* fix read receipts

* fix read receipts fully

* fix unread badge if messages have been read in focus

* foreground promotion fix

* fix app kill in notification

* adjust to new tor

* nice

* about sheet design

* bump version 1.6.0 (#524)

* Automated update of relay data - Sun Dec 14 06:06:53 UTC 2025

* bump targetSdk (#526)

* Automated update of relay data - Sun Dec 21 06:06:56 UTC 2025

* Automated update of relay data - Sun Dec 28 06:07:12 UTC 2025

* Prevent quit notification from reappearing (#530)

* shutdown sequence

* Prevent quit notification from reappearing

* Restrict force-finish broadcast

* Cancel quit shutdown on relaunch

* fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork

* Merge branch 'upstream/main' into fix/wifi-aware-socket-binding

* Fix Wi-Fi Aware connectivity and UI integration post-merge

- Replace bindProcessToNetwork with bindSocket for VPN compatibility.
- Implement Scoped IPv6 address resolution (aware0) for mesh routing.
- Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility.
- Fix syntax errors and variable name conflicts in Debug UI.

* Enhance Wi-Fi Aware robustness and debug UI display

- Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection.
- Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI.
- Fix Map type mismatch warning in ChatViewModel bridge.
- Filter self-ID from peer cleanup tables to prevent recursive self-removal.

* Share GossipSyncManager across transports to prevent redundant message synchronization

- Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder.
- Modified WifiAwareMeshService to use the shared GossipSyncManager if available.
- Added background cleanup for peer mappings on socket disconnection.
- Fixed Kotlin type mismatch during nickname map merging.

* Restore VPN acquisition logic and improve peer cleanup

- Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active.
- Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs.
- Switch cleanup logging to debug level to reduce log noise.

* fix wifi aware routing

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: aidenvalue <>

* unify connection tracker for ble and wifi (#537)

* unify connection tracker for ble and wifi

* cleanup nicely

* Wifi aware skip bluetooth (#538)

* unify connection tracker for ble and wifi

* bluetooth optional

* refactor mesh core

* tests

* share gossip

* refresh peer list more often

* patches: wifi aware mesh refactor core (#549)

* Refactor WifiAware memory management and lifecycle handling

* Fix Wi-Fi Aware reconnection by ensuring callback unregistration and session watchdog

* fix(wifiaware): allow WifiAwareController to restart service if session drops

* Enhance WiFi Aware logging with network request timeouts and detailed callback status

* Ensure explicit release of WiFi Aware network callbacks on failure or disconnection

---------

Co-authored-by: aidenvalue <>

* fix: wifi aware mesh private dms (#561)

* fix(wifi-aware): restore peer check and remove aggressive session restarts

* synchronized access to sockets.

* clear old peer socket `onClientConnected` if one was found.

* address second round of review

* fix: Restart publish session on termination in WifiAwareMeshService

* fix: cleanup connection tracker resources when handling peer disconnection without active socket

---------

Co-authored-by: aidenvalu3 <>

* fix(mesh): address memory leaks and background persistence issues in Wi-Fi Aware

- Move message persistence and background notifications for Wi-Fi Aware into the service layer via MeshCore hooks.
- Fix memory leak in MainActivity by properly detaching WifiAwareMeshDelegate on pause.
- Ensure BluetoothMeshService is initialized before WifiAwareMeshService to guarantee shared GossipSyncManager instance.
- Deduplicate persistence logic from MainActivity delegate.

* wifi aware working

* more robust

* fragments work

* new files

* wifi improvements

* handshake works

* improve

* wifi mesh more robust

* fix review

* check for support

* wifi aware fixes

---------

Co-authored-by: aidenvalue <>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: aidenvalu3 <erendentman@gmail.com>
Co-authored-by: a1denvalu3 <43107113+a1denvalu3@users.noreply.github.com>
Co-authored-by: CC <cc@ggg.local>
2026-06-16 12:11:33 -05:00
callebtc
6189f0cb84
fix(security): apply iOS audit parity fixes (#709)
Co-authored-by: CC <cc@ggg.local>
2026-06-15 08:35:37 -05:00
GitHub Action
13585a9a9c Automated update of relay data - Sun Jun 14 07:39:17 UTC 2026 2026-06-14 07:39:17 +00:00
a1denvalu3
b079ae8742
fix(sync): resolve hash alignment and early break bugs on GCS filter trimming (#708)
* fix(sync): resolve hash alignment and early break bugs on GCS filter trimming

* fix(sync): filter out zero bucket and deduplicate buckets before encoding

* fix(sync): preserve zero-bucket mapping by clamping 0 to 1 during encoding and decoding
2026-06-08 19:00:03 -05:00
callebtc
054887bad8
Fix request sync duplicate public messages (#707)
Co-authored-by: CC <cc@ggg.local>
2026-06-08 15:41:11 -05:00
GitHub Action
f7ce1f4be6 Automated update of relay data - Sun Jun 7 07:27:22 UTC 2026 2026-06-07 07:27:22 +00:00
GitHub Action
1a99064fc7 Automated update of relay data - Sun May 31 07:22:04 UTC 2026 2026-05-31 07:22:04 +00:00
GitHub Action
2a8dbac89d Automated update of relay data - Sun May 24 07:09:35 UTC 2026 2026-05-24 07:09:35 +00:00
GitHub Action
67e767237b Automated update of relay data - Sun May 17 07:01:09 UTC 2026 2026-05-17 07:01:09 +00:00
GitHub Action
8ddf03d139 Automated update of relay data - Sun May 10 06:58:05 UTC 2026 2026-05-10 06:58:05 +00:00
GitHub Action
1d99434a25 Automated update of relay data - Sun May 3 06:54:32 UTC 2026 2026-05-03 06:54:32 +00:00
GitHub Action
911c80db0e Automated update of relay data - Sun Apr 26 06:41:15 UTC 2026 2026-04-26 06:41:16 +00:00
GitHub Action
c332fb2ff1 Automated update of relay data - Sun Apr 19 06:37:36 UTC 2026 2026-04-19 06:37:36 +00:00
GitHub Action
47adcca1e1 Automated update of relay data - Sun Apr 12 06:35:32 UTC 2026 2026-04-12 06:35:32 +00:00
GitHub Action
42f6cf9d7f Automated update of relay data - Sun Apr 5 06:29:20 UTC 2026 2026-04-05 06:29:20 +00:00
callebtc
4dfec917c8
Revert "voice note player ui (#680)" (#690)
This reverts commit bc8909bab0d40797e57c2f791e7a1a4b0ce5b842.
2026-03-30 10:21:38 +02:00
callebtc
e3c7bd5346
Revert "update input button styling (#682)" (#689)
This reverts commit ce86f4ec2fcecf7067ca850a42dd585f6eeded71.
2026-03-30 10:20:25 +02:00
callebtc
be1a2ec914
update version (#688) 2026-03-30 10:14:32 +02:00
GitHub Action
426e4f0994 Automated update of relay data - Sun Mar 29 06:27:23 UTC 2026 2026-03-29 06:27:23 +00:00
Ovi
5b0a7d0ce9
fix: add input validation for protocol decoding and fragment reassembly (#666)
* fix: add input validation for protocol decoding and fragment reassembly

* fix:subtract the old entry's size before adding the new one, so duplicate/retransmitted fragments don't inflate the counter

* fix: FragmentManager.handleFragment() can be entered concurrently (e.g., fragments for the same fragmentID arriving from multiple peers/relays). In order for this to happen multiple devices would be needed to connect to the mesh. even with a per-fragmentID byte cap, an attacker could open many fragment IDs at once and force the device to buffer lots of fragment data overall (risking memory pressure/OOM). In this fix we made fragments atomic and thread safe. When a fragment index is retransmitted, we compute the size delta (new - old) so duplicates don’t inflate counters and can’t be used to bypass limits. Under heavy load/attack the app will drop/reject fragment sets earlier instead of growing memory usage without bound to reduce risk of oom. tested on pixel 6 and pixel 8.

* fix: add fragmenttest
2026-03-26 16:24:29 +01:00
a1denvalu3
96b35e957c
Add 'Send private message' action to chat user sheet (#649)
* Add 'Send private message' action to chat user sheet

* Change 'Send private message' action color to purple

* Fix: Resolve short ID against all participants, not just cached nicknames

* Fix: Correctly open private chat sheet for mesh peers

---------

Co-authored-by: a1denvalu3 <>
2026-03-25 14:45:36 +01:00
a1denvalu3
dac81b2c3b
fix: use CSPRNG for heartbeat intervals (#685) 2026-03-25 14:42:53 +01:00
Clare Kinery
ce86f4ec2f
update input button styling (#682) 2026-03-25 14:42:24 +01:00
GitHub Action
95daa47a5b Automated update of relay data - Sun Mar 22 06:19:38 UTC 2026 2026-03-22 06:19:38 +00:00
GitHub Action
9fb8d4b37e Automated update of relay data - Sun Mar 15 06:23:41 UTC 2026 2026-03-15 06:23:42 +00:00
GitHub Action
4082a48154 Automated update of relay data - Sun Mar 8 06:15:21 UTC 2026 2026-03-08 06:15:21 +00:00
rollforsats
9ad1214e96
Unit tests for BinaryProtocol (#678)
* Comprehensive tests for BinaryProtocol

* Test feedback
2026-03-06 13:08:18 +01:00
Clare Kinery
bc8909bab0
voice note player ui (#680) 2026-03-06 13:07:45 +01:00
GitHub Action
632ee884e9 Automated update of relay data - Sun Mar 1 06:17:19 UTC 2026 2026-03-01 06:17:19 +00:00
callebtc
fb2bb64d65
chore: change license from MIT to GPLv3 (#674) 2026-02-28 13:09:04 +01:00
GitHub Action
4f62e6828b Automated update of relay data - Sun Feb 22 06:17:38 UTC 2026 2026-02-22 06:17:38 +00:00
GitHub Action
dc8b6ee37e Automated update of relay data - Sun Feb 15 06:19:54 UTC 2026 2026-02-15 06:19:54 +00:00
GitHub Action
6d44919f5b Automated update of relay data - Sun Feb 8 06:20:17 UTC 2026 2026-02-08 06:20:17 +00:00
callebtc
3e5355a71d
edit 2026-02-03 15:33:56 +01:00
callebtc
390f264987
bump (#661) 2026-02-01 13:33:22 +01:00
a1denvalu3
43029e88e6
fix: pause geohash heartbeat (kind 20001) sampling when backgrounded (#660) 2026-02-01 13:25:36 +01:00
GitHub Action
73d8ce56a2 Automated update of relay data - Sun Feb 1 06:19:30 UTC 2026 2026-02-01 06:19:30 +00:00
a1denvalu3
f79451b615
fix(geohash): cleanup sampling subscriptions when closing location sheet (#659)
- Added DisposableEffect to LocationChannelsSheet to ensure cleanup runs when composable is removed
- Fixed ChatViewModel.endGeohashSampling() to correctly delegate to GeohashViewModel instead of being a no-op
- Prevents lingering kind 20001 subscriptions after closing the sheet
2026-01-31 16:42:20 +01:00
GitHub Action
06d2665b70 Automated update of relay data - Sun Jan 25 06:07:49 UTC 2026 2026-01-25 06:07:49 +00:00
GitHub Action
46e30897ae Automated update of relay data - Sun Jan 18 06:07:42 UTC 2026 2026-01-18 06:07:42 +00:00
callebtc
ab04764212
Fix location update in notes sheet and Nostr subscription leaks (#636)
- Fixes #634: Trigger location refresh when LocationNotesSheet opens
- Fixes #635: Implement proper subscription diffing and cleanup in GeohashViewModel
2026-01-17 00:47:53 +07:00
callebtc
e3056d70c0
bump to 1.7.0 (#624) 2026-01-16 01:13:33 +07:00
Moe Hamade
5013b32ca9
feat: Introduce Gradle property to control APK splits (#560)
* feat: Introduce Gradle property to control APK splits

This commit introduces a new Gradle project property, `buildSplitApks`, to conditionally enable or disable the generation of ABI-specific (arm64, x86_64) and universal APKs.

Key changes:
- In `app/build.gradle.kts`, the `splits.abi.isEnable` flag is now dynamically set based on the `buildSplitApks` property.
- APK splitting is disabled by default to support standard Android App Bundle (`bundleRelease`) builds.
- The release workflow (`release.yml`) is updated to pass `-PbuildSplitApks=true` when building release APKs for GitHub.
- The general Android build workflow (`android-build.yml`) is also modified to enable splits only for the `Release` variant, ensuring debug builds are not affected.

* chore: Simplify and automate APK split builds

This commit simplifies the build process by automatically enabling ABI splits for APKs (`assemble`) and disabling them for AABs (`bundle`).

This removes the need to manually pass the `-PbuildSplitApks=true` property. The build script now intelligently determines whether to create architecture-specific APKs based on the task being executed (e.g., `assembleRelease` vs. `bundleRelease`).

The GitHub Actions workflows (`release.yml`, `android-build.yml`) have been updated to remove this now-redundant property, streamlining the CI configuration.
2026-01-16 01:06:15 +07:00
callebtc
e07bee5a75
spacing for geohash sheet text (#623) 2026-01-16 01:03:25 +07:00
yet300
51a06cf0b7
Refactored BottomSheetTopBar to BitchatSheetTopBar (#601)
* Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025

* Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025

* refactor: new close button like ios(but not liquid glass)

* Automated update of relay data - Sun Oct  5 06:20:09 UTC 2025

* Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025

* Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025

* Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025

* Automated update of relay data - Sun Nov  2 06:22:16 UTC 2025

* Automated update of relay data - Sun Nov  9 06:21:43 UTC 2025

* Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025

* Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025

* Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025

* Automated update of relay data - Sun Dec  7 06:22:59 UTC 2025

* Automated update of relay data - Sun Dec 14 06:24:33 UTC 2025

* Automated update of relay data - Sun Dec 21 06:24:49 UTC 2025

* Automated update of relay data - Sun Dec 28 06:25:38 UTC 2025

* Automated update of relay data - Sun Jan  4 06:26:28 UTC 2026

* Automated update of relay data - Sun Jan 11 06:26:19 UTC 2026

* feat: Add BitchatSheetTopBar component

* Refactor: Use BitchatSheetTopBar in bottom sheets

This commit refactors several bottom sheet components to use the new reusable `BitchatSheetTopBar` composable.

This change provides a consistent look and feel for top bars across the following sheets:
- DebugSettingsSheet
- MeshPeerListSheet
- LocationNotesSheet
- LocationChannelsSheet
- LocationNotesSheetPresenter (for the location unavailable state)

The `BitchatSheetCenterTopBar` has been removed as its functionality is now covered by the more flexible `BitchatSheetTopBar`.

* Refactor: Use BitchatSheetTopBar in MeshPeerListSheet

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-16 01:00:04 +07:00
Anupam Kumar
fddadbe44a
Fix/590-Private-key-stored-as-Plain-text (#603)
* Private Key Stored in Plaintext(SharedPreference) in EncryptionService migrated to EncryptedSharedPreferences.

* Fix: Safe migration to EncryptedSharedPreferences and restore testability

- Use distinct filename for encrypted prefs to avoid collision with legacy plaintext file
- Move Keystore setup to initialize() to support Robolectric test mocks
- Implement safe read-old/write-new migration logic

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2026-01-15 23:03:10 +07:00
a1denvalu3
a13109970d
fix(ui): Show acquiring location state instead of unavailable (#621)
* fix(ui): Show acquiring location state instead of unavailable

This change updates the LocationNotesSheetPresenter to display an 'Acquiring Location' sheet when location permissions are granted but the location is still loading. This prevents the misleading 'Location Unavailable' error on devices with slow GPS start-up (e.g., GrapheneOS).

Fixes #578

* Adjust text

---------

Co-authored-by: a1denvalu3 <>
2026-01-15 23:02:26 +07:00
callebtc
db26b673ee
fix: Ensure empty neighbor lists in newer announcements clear mesh edges (#620)
* fix: Ensure empty neighbor lists in newer announcements clear mesh edges

- Refactored MeshGraphService.updateFromAnnouncement to prioritize timestamp checks.
- Treated null neighbor lists (omitted TLV) as empty lists to allow peer disconnection/isolation updates to propagate.
- Added MeshGraphServiceTest to verify timestamp logic and edge eviction.

* fix(test): Use TestOnly API to reset singleton instead of reflection

- Added MeshGraphService.resetForTesting()
- Updated MeshGraphServiceTest to use the new API, avoiding fragile reflection on companion object fields.
2026-01-15 23:01:56 +07:00
a1denvalu3
39e43fa923
security: ensure media files are deleted during panic mode (#607)
Closes #591
- Added FileUtils.clearAllMedia() to recursively delete media directories and cache.
- Called clearAllMedia() in ChatViewModel.panicClearAllData().

fix: correct voice notes directory path for cleanup

- Updated FileUtils.clearAllMedia to use 'voicenotes' instead of 'voice_notes' to match VoiceRecorder.kt

fix: update media cleanup to include cache directories

- Updated FileUtils.clearAllMedia to explicitly clean 'files/incoming' and 'images/incoming' from context.cacheDir, reflecting the storage location change from issue #592.
- Maintained legacy cleanup for context.filesDir.

Co-authored-by: a1denvalu3 <>
2026-01-15 22:32:30 +07:00
callebtc
5501141ae0
Fix: Correctly parse recipient/sender IDs in broadcaster to enable unicast (#619)
* Fix: Unicast source-routed packets at origin instead of broadcasting

* Fix: Correctly parse recipient/sender IDs as hex strings in broadcaster
2026-01-15 16:32:40 +07:00
callebtc
c64ea0a021
fix: centralize and strictly enforce connection limits (#618) 2026-01-15 16:00:48 +07:00
callebtc
83fbcca557
Implement iOS-compatible Direct Peer Detection (TTL Logic) (#574)
* Clean up direct peer detection logic (Max TTL) and remove unrelated routing/media changes

* cleanup fix
2026-01-15 15:24:24 +07:00
callebtc
8358073420
improve verification sheet (#615) 2026-01-15 15:11:36 +07:00
callebtc
ae67fa4344
fix(security): Clear in-memory keys during panic mode (#596)
* fix(security): Clear in-memory keys during panic mode #588

* feat: Recreate mesh service after panic clear (#602)

* feat: Recreate mesh service after panic clear

This commit refactors the panic clear process to ensure a new mesh identity is immediately created and applied.

Previously, the `ChatViewModel` would clear sensitive data, but the recreation of the `BluetoothMeshService` was handled externally. This could lead to a delay or failure in adopting the new identity.

Key changes:
- Introduces `MeshServiceHolder` to manage the lifecycle of the `BluetoothMeshService` instance.
- Adds `recreateMeshServiceAfterPanic()` to `ChatViewModel`, which now explicitly clears the old service instance and creates a new one with a regenerated identity.
- The `meshService` property in `ChatViewModel` is now a `var` to allow it to be replaced with the fresh instance post-panic.
- The new service is started, and a broadcast announcement is sent immediately, ensuring the new peer ID is used on the network.

* fix: Ensure mesh service is properly managed in foreground service

* refactor: Decouple handlers from direct service reference

This commit updates the `VerificationHandler` and `MediaSendingManager` to receive the `meshService` via a lambda function (`getMeshService`) instead of a direct reference.

This change decouples the handlers from the service instance, preventing them from holding a stale reference if the service reconnects or changes. By invoking the lambda to get the current service instance when needed, it ensures they always interact with the active `meshService`.

* fix: restart bluetooth

---------

Co-authored-by: Moe Hamade <69801237+moehamade@users.noreply.github.com>
2026-01-15 15:11:10 +07:00
callebtc
9dfebd73db
Refactor: Implement Hybrid Location Provider (System/Fused) (#612)
* refactor: abstract LocationProvider, use FusedLocationProvider

* Refactor: Sync permission state on check

Replaces manual updatePermissionState calls with a unified checkAndSyncPermission method. This ensures that _permissionState flow always reflects the actual system permission status whenever it is checked (e.g. in requestOneShotLocation), preventing desync issues when permissions are revoked at runtime.

* Refactor: Add timeout and tracking to SystemLocationProvider

Implements robust cleanup and timeout logic for location requests.
- SystemLocationProvider: Adds 30s timeout and listener tracking for legacy one-shot requests to prevent memory leaks on pre-Android 11 devices.
- FusedLocationProvider: Adds 30s duration to requests.
- LocationProvider: Adds cancel() method for full resource cleanup.
- LocationChannelManager: Ensures cancel() is called during cleanup.

* try catch
2026-01-15 14:30:26 +07:00
callebtc
c2f60d6ab2
announce peer ID in servicedata to prevent duplicate connections (#613) 2026-01-15 13:51:30 +07:00
a1denvalu3
e3310a34fd
security: prevent storage exhaustion by saving incoming files to cache (#606)
Closes #592
- Changed FileUtils.saveIncomingFile to use context.cacheDir instead of context.filesDir.
- This ensures incoming files are treated as temporary and can be cleared by the OS if space is needed.

Co-authored-by: a1denvalu3 <>
2026-01-15 13:42:50 +07:00
callebtc
41945e6ff0
OSM fallback for geocoding (#611)
* OSM fallback for geocoding

* fallback to city for missing province
2026-01-15 11:45:22 +07:00
callebtc
5ec02c99fd
Fix and Improve LocationManager logic (#599)
* location manager improvements

* fix: resolve compilation errors and improve location state handling

- Fix missing imports and stray braces in LocationChannelManager
- Implement checkSystemLocationEnabled and BroadcastReceiver for location state
- Order class members for correct initialization
- Consolidate UI refresh logic in LocationChannelsSheet

* fix: guard against stale geocoding results on legacy devices

- Add isActive check after blocking Geocoder.getFromLocation call
- Prevent stale results from overwriting state after job cancellation
2026-01-15 11:11:56 +07:00
callebtc
e769a03634
Fix/nostr dm bottom sheet (#598)
* nostr in new dm sheets

* open sheet for nostr peers
2026-01-14 17:38:47 +07:00
callebtc
216352c39d
use bluetooth icon (#597) 2026-01-14 17:17:19 +07:00
yet300
94b28b2107
Move PrivateChatSheet hosting into ChatDialogs and unify sheet state (#586)
* Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025

* Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025

* refactor: new close button like ios(but not liquid glass)

* Automated update of relay data - Sun Oct  5 06:20:09 UTC 2025

* Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025

* Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025

* Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025

* Automated update of relay data - Sun Nov  2 06:22:16 UTC 2025

* Automated update of relay data - Sun Nov  9 06:21:43 UTC 2025

* Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025

* Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025

* Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025

* Automated update of relay data - Sun Dec  7 06:22:59 UTC 2025

* Automated update of relay data - Sun Dec 14 06:24:33 UTC 2025

* Automated update of relay data - Sun Dec 21 06:24:49 UTC 2025

* Automated update of relay data - Sun Dec 28 06:25:38 UTC 2025

* Automated update of relay data - Sun Jan  4 06:26:28 UTC 2026

* Automated update of relay data - Sun Jan 11 06:26:19 UTC 2026

* feat: Show private chat in sheet from notification

* Refactor: Hoist private chat sheet state to ChatViewModel

* remove icon

* remove old bottom sheet

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2026-01-14 17:12:40 +07:00
callebtc
829fb52a04
Update arti to 1.9.0 and produce 32 bit builds (#585)
* x86

* update arti to 1.9.0
2026-01-14 14:58:26 +07:00
callebtc
dd856ac01f
visual logger refactor (#583) 2026-01-14 04:40:04 +07:00
callebtc
ce71de29f7
Feat/graph force (#581)
* mesh topology wip

* graph

* graph animated
2026-01-13 05:11:20 +07:00
callebtc
66012e9fe9
Gossip mesh topology + source-based routing (#445)
* wip mesh graph

* gossip fix

* gossip works

* source-based routing wip

* log

* update spec to be explicit about intermediate hops only

* drop duplicate hops

* add to spec

* test

* forgot comma

* source routing v2

* add compression bomb protection

* v2 source routing

* fragmented packets inherit route

* update spec

* Gossip routing tmp with connection limit fixed (#569)

* fix: r8 exception for LocationManager (#566)

* fragmented packets inherit route

* update spec

* fix connection limits

* fix: deserialization issue with routed packets

* log

* dynamic fragment size

* fragment size

* add tests

* feat(gossip): implement two-way handshake for source routing edges

- Update MeshGraphService to track directed announcements
- Require bidirectional announcements for a 'confirmed' edge
- Update RoutePlanner to strictly use confirmed edges
- Update Mesh Topology debug view to show confirmed vs unconfirmed edges (solid vs dotted)

* docs: update SOURCE_ROUTING.md with two-way handshake requirement

* evict stale peers from mesh graph service

* better logging

* fix announce spe

* fix: empty route

* fix spec

* fix: compile error in DebugSettingsSheet and potential NPE in RoutePlanner

* revert

* try again
2026-01-13 04:18:07 +07:00
callebtc
d164b3f9bc
Implement Geohash Presence (Heartbeats) (#576)
* geohash announce

* only for some geohashes

* global presence right away

* jitter delays

* show ? people for high-precision geohashes

* 1000 events
2026-01-13 03:23:53 +07:00
yet300
654d385b6d
UI Refactor (#562)
* Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025

* Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025

* refactor: new close button like ios(but not liquid glass)

* Automated update of relay data - Sun Oct  5 06:20:09 UTC 2025

* Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025

* Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025

* Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025

* Automated update of relay data - Sun Nov  2 06:22:16 UTC 2025

* Automated update of relay data - Sun Nov  9 06:21:43 UTC 2025

* Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025

* Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025

* Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025

* Automated update of relay data - Sun Dec  7 06:22:59 UTC 2025

* Automated update of relay data - Sun Dec 14 06:24:33 UTC 2025

* Automated update of relay data - Sun Dec 21 06:24:49 UTC 2025

* Automated update of relay data - Sun Dec 28 06:25:38 UTC 2025

* Automated update of relay data - Sun Jan  4 06:26:28 UTC 2026

* refactor: Extract CloseButton to core UI components

* feat: Add BitchatBottomSheet component

* refactor: Use BitchatBottomSheet component and minor ui change(CloseButton, colors)

* Refactor: Use BitchatBottomSheet in MeshPeerListSheet

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-12 15:29:09 +07:00
yet300
e40fd106d7
New MeshPeerListSheet as Ios like (#498)
* Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025

* Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025

* refactor: new close button like ios(but not liquid glass)

* Automated update of relay data - Sun Oct  5 06:20:09 UTC 2025

* Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025

* Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025

* Refactor: Redesign peer and channel list as a bottom sheet

Replaced the right-hand sidebar with a Material 3 `ModalBottomSheet` for displaying mesh peers and channels. This modernizes the UI and improves usability.

- Renamed `SidebarComponents.kt` to `MeshPeerListSheet.kt`.
- Replaced the custom sidebar implementation with `ModalBottomSheet`.
- Added a floating top bar to the sheet that appears on scroll, displaying the title and a close button.
- Updated the row layouts for both channels and peers to use `Surface` for better visual grouping and selection state handling.
- Added a checkmark icon to indicate the currently selected channel or private chat peer.
- Improved styling for section headers, unread badges, and empty-state text.
- Removed the `SignalStrengthIndicator` as it was no longer used.

* Refactor: Remove sidebar state management from ViewModel

This commit removes the state management for the sidebar's visibility from `ChatViewModel` and `ChatState`.

The sidebar's visibility is now a purely UI-level concern and is no longer coupled with the ViewModel's logic. This change simplifies the ViewModel by removing unnecessary LiveData and related methods (`showSidebar`, `hideSidebar`). The back navigation handler has also been updated to remove the case for closing the sidebar.

* Refactor: Replace Sidebar with MeshPeerList Bottom Sheet

* feat: Add nested private chat sheet

* Feat: Enhance UI/UX of LocationNotesSheet

This commit refactors the `LocationNotesSheet` to more closely align with its iOS counterpart, improving both its appearance and user experience.

The layout has been updated to use a `Box` with aligned elements instead of a single `Column`, allowing for a floating input section at the bottom and a floating close button at the top right.

**Key Changes:**

-   **Floating Top Bar and Input:**
    -   The main content is now a `LazyColumn` that scrolls underneath a new floating top bar and a floating input section at the bottom.
    -   The top bar's background animates from transparent to semi-opaque as the user scrolls, providing a "blur" effect.

-   **iOS-Style Close Button:**
    -   The close button is moved from the header row to the top-right corner of the sheet, where it remains fixed.

-   **Structural Refinements:**
    -   Replaced the main `Column` with a `Box` to manage the layout of the scrollable content, top bar, and input section.
    -   Removed the `onClose` parameter from `LocationNotesHeader` as the close button is now managed separately.
    -   Added `statusBarsPadding` to the `ModalBottomSheet` to prevent content from rendering under the system status bar.
    -   Adjusted spacing and padding for better visual consistency.

* refactor: Use collectAsStateWithLifecycle

Migrates LiveData observation from `observeAsState` to `collectAsStateWithLifecycle` for improved lifecycle-aware state collection in `MeshPeerListSheet`.

This also includes the following related changes:
*   Removes an unused `showSidebar` state flow from `ChatViewModel`.
*   Replaces fully qualified `com.bitchat.android.ui.splitSuffix` calls with a direct `splitSuffix` call.
*   Updates resource string access to use the `R` import.

* feat: Add verification status indicators

- Add peer verification status icons to the peer list.
- Add a button to the channel list to show the verification QR code.
- Remove the unused `SidebarComponents.kt` file.

* Refactor: Add state for mesh peer list visibility

* Refactor: Move mesh peer list state to ViewModel

* refactor: Move QR code icon from channel items to footer

The verification (QR code) icon is relocated from being repeated on each channel list item to a single, centralized position in the sheet's footer.

This icon is now only displayed when not in a location-based channel. The `onShowVerification` parameter has been removed from `ChannelListItem` as it's no longer needed there.

* feat: Show verified status for peers

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-12 15:10:07 +07:00
a1denvalu3
c7e20a9590
fix nostr private messages processing (#563)
* fix nostr private messages processing

* persisted geohash registry

---------

Co-authored-by: a1denvalu3 <>
2026-01-12 14:53:53 +07:00
yet300
cbe7a2fc95
Fix scroll bug(#568) (#570)
* Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025

* Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025

* refactor: new close button like ios(but not liquid glass)

* Automated update of relay data - Sun Oct  5 06:20:09 UTC 2025

* Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025

* Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025

* Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025

* Automated update of relay data - Sun Nov  2 06:22:16 UTC 2025

* Automated update of relay data - Sun Nov  9 06:21:43 UTC 2025

* Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025

* Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025

* Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025

* Automated update of relay data - Sun Dec  7 06:22:59 UTC 2025

* Automated update of relay data - Sun Dec 14 06:24:33 UTC 2025

* Automated update of relay data - Sun Dec 21 06:24:49 UTC 2025

* Automated update of relay data - Sun Dec 28 06:25:38 UTC 2025

* Automated update of relay data - Sun Jan  4 06:26:28 UTC 2026

* fix bug(568): Improve scroll-to-bottom logic

Replaced the "smart scroll" mechanism with a simpler "follow" behavior. The message list now automatically scrolls to the latest message unless the user has scrolled up.

- A new `followIncomingMessages` state tracks whether to auto-scroll.
- Scrolling now uses `scrollToItem` instead of `animateScrollToItem` for immediate updates.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-01-12 14:43:28 +07:00
GitHub Action
fbcc289e92 Automated update of relay data - Sun Jan 11 06:07:53 UTC 2026 2026-01-11 06:07:53 +00:00
callebtc
0306ed4f34
fix camera button (#572)
* fix camera button

* fix

* Fix: Add runtime camera permission check for image picker
2026-01-10 01:04:48 +07:00
callebtc
d975b846be
fix: MessageRouter uses stale BluetoothMeshService reference (#571)
* fix: MessageRouter uses stale BluetoothMeshService reference

* fix: unconditionally update mesh reference in MessageRouter.getInstance
2026-01-09 23:56:16 +07:00
callebtc
e313112d7c
fix: r8 exception for LocationManager (#566) 2026-01-08 17:31:50 +07:00
callebtc
5dc6369b8f
bump vc to 30 (#559) 2026-01-05 17:30:28 +07:00
Moe Hamade
68407e3b47
feat: Build and release per-architecture APKs (#550)
* feat: Build and release per-architecture APKs

This commit modifies the build process to generate separate APKs for different CPU architectures (arm64-v8a, x86_64) and a universal APK. This allows for smaller, optimized downloads for users and provides specific builds for platforms like Chromebooks.

Key changes:
- In `app/build.gradle.kts`, enables ABI splits to create `arm64-v8a`, `x86_64`, and `universal` APKs during the `assembleRelease` task.
- Updates the `release.yml` GitHub Actions workflow to rename and upload each of these APKs as distinct release assets.
- Removes the previous `arm64-v8a` only filter to allow for multi-architecture builds.

* feat: Allow manual triggering of Android CI workflow

This adds the `workflow_dispatch` event to the `android-build.yml` GitHub Actions workflow.

This change enables the Android CI pipeline to be run manually from the GitHub UI, in addition to the existing triggers for pushes and pull requests.
2026-01-05 17:28:53 +07:00
callebtc
eef831fe60
scan duration fix (#557) 2026-01-05 16:41:41 +07:00
callebtc
171483a1da
drop duplicate announces based on TTL (#558) 2026-01-05 16:41:17 +07:00
callebtc
423feb8b77
Fix/foreground permissions scanning (#556)
* try catch the foreground location service if not available

* ask for background permissions

* background permissions in onboarding

* small improvements
2026-01-05 15:09:04 +07:00
callebtc
9733806610
bump to 29 (#553) 2026-01-05 11:39:13 +07:00
callebtc
95385daaf9
verift signature only for ANNOUNCE, MESSAGE, and FILE_TRANSFER like ios (#552) 2026-01-05 11:22:06 +07:00
callebtc
eeaabf487f
bump versioncode to 28 (#551) 2026-01-05 10:42:35 +07:00
callebtc
7ee61a0440
fix: sync notification peer count with sidebar (#547) 2026-01-04 21:12:51 +07:00
callebtc
fe5417bf97
fix: location channel manager (#516)
* location channel manager fix

* fix: use interval arg in beginLiveRefresh & remove unused code
2026-01-04 21:09:14 +07:00
callebtc
123a24ce34
Revert "Fix small-screen header clipping (#519) (#532)" (#548)
This reverts commit d73976537d6df37f794063c4b6e94981ec6af96e.
2026-01-04 21:08:52 +07:00
callebtc
55b2d68def
Enforce mandatory packet signature verification (#546)
* security: enforce mandatory packet signature verification

* tests: add comprehensive unit tests for packet signature verification
2026-01-04 18:47:24 +07:00
yet300
c663e8ede0
QR and Verification feature (#529)
* Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025

* Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025

* refactor: new close button like ios(but not liquid glass)

* Automated update of relay data - Sun Oct  5 06:20:09 UTC 2025

* Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025

* Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025

* Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025

* Automated update of relay data - Sun Nov  2 06:22:16 UTC 2025

* Automated update of relay data - Sun Nov  9 06:21:43 UTC 2025

* Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025

* Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025

* Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025

* Automated update of relay data - Sun Dec  7 06:22:59 UTC 2025

* Automated update of relay data - Sun Dec 14 06:24:33 UTC 2025

* Automated update of relay data - Sun Dec 21 06:24:49 UTC 2025

* Automated update of relay data - Sun Dec 28 06:25:38 UTC 2025

* feat: Add ZXing dependency for QR code scanning

* feat: Request camera permission for QR verification

* Add QR verification payloads and mesh wiring

* Wire verification state, system messages, and notifications

* Add verification sheets and UI affordances

* Show verified badges in sidebar and add strings

* Persist fingerprint caches for offline verification

* Handle bitchat://verify deep links

* feat: Replace zxing-android-embedded with ML Kit and CameraX

* Refactor(Verification): Replace zxing with MLKit for QR scanning

* Replace `AndroidView` with `CameraXViewfinder` for camera preview

* Refactor QR verification: Extract VerificationHandler and fix concurrency issues

* Extract and translate strings for QR verification feature

* Fix build errors: Escape ampersands in strings and restore missing methods in ChatViewModel

* return to main

* return to main 2

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2026-01-04 16:29:07 +07:00
kargathara Aakash
d73976537d
Fix small-screen header clipping (#519) (#532)
* Fix small-screen header clipping: reserve status-bar space and center-constrain titles (fixes #519)

* Remove redundant Gradle configuration lines
2026-01-04 13:57:55 +07:00
Rebroad
54c96bd737
Fix: Add @ConscryptMode annotation to FileTransferTest to resolve UnsatisfiedLinkError (#543)
Robolectric 4.9+ uses Conscrypt as the default security provider, which
requires native libraries that aren't available in the test environment.
Adding @ConscryptMode(Mode.OFF) disables Conscrypt and uses BouncyCastle
instead, which resolves the test failures.

This fixes the build failures in FileTransferTest where all 9 tests were
failing with UnsatisfiedLinkError when Conscrypt tried to load native
libraries.

Fixes #542
2026-01-04 13:41:38 +07:00
GitHub Action
7c183b0c8d Automated update of relay data - Sun Jan 4 06:08:02 UTC 2026 2026-01-04 06:08:02 +00:00
callebtc
1b9f76be23
Fix notification user try 2 (#541)
* count peers in notification

* cleanup
2026-01-04 02:35:06 +07:00
callebtc
fa1978d587
Revert "fix: wifi aware socket binding (#533)" (#535)
This reverts commit 0c7505b588b38b14f5b674ef6ec4d286cdb0f5c4.
2026-01-03 23:37:49 +07:00
aidenvalue
0c7505b588
fix: wifi aware socket binding (#533)
* wifi aware wip

* wifi aware wip

* starting to work

* werk

* dms work

* wip

* fix(wifi-aware): use bindSocket and scoped IPv6 instead of bindProcessToNetwork

* Merge branch 'upstream/main' into fix/wifi-aware-socket-binding

* Fix Wi-Fi Aware connectivity and UI integration post-merge

- Replace bindProcessToNetwork with bindSocket for VPN compatibility.
- Implement Scoped IPv6 address resolution (aware0) for mesh routing.
- Bridge Wi-Fi Aware incoming messages to AppStateStore for UI visibility.
- Fix syntax errors and variable name conflicts in Debug UI.

* Enhance Wi-Fi Aware robustness and debug UI display

- Clean up transport resources (sockets, server sockets, network callbacks) immediately on peer disconnection.
- Implement resolveScopedAddress to show scoped IPv6 (e.g., %aware0) in Debug UI.
- Fix Map type mismatch warning in ChatViewModel bridge.
- Filter self-ID from peer cleanup tables to prevent recursive self-removal.

* Share GossipSyncManager across transports to prevent redundant message synchronization

- Registered BluetoothMeshService's GossipSyncManager as a singleton in MeshServiceHolder.
- Modified WifiAwareMeshService to use the shared GossipSyncManager if available.
- Added background cleanup for peer mappings on socket disconnection.
- Fixed Kotlin type mismatch during nickname map merging.

* Restore VPN acquisition logic and improve peer cleanup

- Revert removal of NET_CAPABILITY_NOT_VPN to allow hardware handle acquisition while VPN is active.
- Refactor handlePeerDisconnection to more reliably cleanup initial and routed IDs.
- Switch cleanup logging to debug level to reduce log noise.

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
Co-authored-by: aidenvalue <>
2026-01-03 23:31:20 +07:00
callebtc
903a4584a8
Prevent quit notification from reappearing (#530)
* shutdown sequence

* Prevent quit notification from reappearing

* Restrict force-finish broadcast

* Cancel quit shutdown on relaunch
2026-01-02 16:52:06 +07:00
GitHub Action
ab555f25f5 Automated update of relay data - Sun Dec 28 06:07:12 UTC 2025 2025-12-28 06:07:12 +00:00
GitHub Action
7f434a34e7 Automated update of relay data - Sun Dec 21 06:06:56 UTC 2025 2025-12-21 06:06:56 +00:00
callebtc
743bbd0b1c
bump targetSdk (#526) 2025-12-14 21:55:41 +07:00
GitHub Action
2a11d52d3c Automated update of relay data - Sun Dec 14 06:06:53 UTC 2025 2025-12-14 06:06:53 +00:00
callebtc
8767cfdea3
bump version 1.6.0 (#524) 2025-12-13 16:46:09 +07:00
callebtc
3f8c236a72
Background persistence (#505)
* persistence step 1

* fix build

* messages in the background work, notifications not yet

* app state store

* DM icon shows up

* notification launches when app is closed!

* keep ui updated

* lifecycle fixes

* extensive logging, maybe revert later

* send nickname in announcement

* quit in notification

* setting in about sheet

* fix quit bitchat

* lifecycle fixes

* power mode based on background state

* stats for both direciotns

* fix graph persistence

* better counting

* count per device

* only compute when debug sheet is open? untested

* fix read receipts

* fix read receipts fully

* fix unread badge if messages have been read in focus

* foreground promotion fix

* fix app kill in notification

* adjust to new tor

* nice

* about sheet design
2025-12-13 16:43:39 +07:00
yet300
e96330e50b
Migrate from LiveData to Kotlin Flow (#518)
* Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025

* Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025

* refactor: new close button like ios(but not liquid glass)

* Automated update of relay data - Sun Oct  5 06:20:09 UTC 2025

* Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025

* Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025

* Automated update of relay data - Sun Oct 26 06:21:31 UTC 2025

* Automated update of relay data - Sun Nov  2 06:22:16 UTC 2025

* Automated update of relay data - Sun Nov  9 06:21:43 UTC 2025

* Automated update of relay data - Sun Nov 16 06:22:37 UTC 2025

* Automated update of relay data - Sun Nov 23 06:22:51 UTC 2025

* Automated update of relay data - Sun Nov 30 06:24:08 UTC 2025

* Chore: Remove unused `lifecycle-livedata-ktx` dependency

This commit removes the `androidx.lifecycle:lifecycle-livedata-ktx` library from the project's dependencies.

The `[libraries]` and `[bundles]` sections in `gradle/libs.versions.toml` have been updated to reflect this removal, as the dependency is no longer in use.

* Refactor: Remove unused `runtime-livedata` dependency

* Refactor: Migrate `LocationChannelManager` and `GeohashBookmarksStore` to StateFlow

This commit refactors `LocationChannelManager` and `GeohashBookmarksStore` to use `StateFlow` instead of `LiveData` for managing and exposing their state. This change aligns with modern Android development practices and improves testability.

**Key Changes:**

- **`LocationChannelManager`**:
    - All `MutableLiveData` properties (`permissionState`, `availableChannels`, `selectedChannel`, etc.) have been replaced with `MutableStateFlow`.
    - Consumers now access these properties as `StateFlow`.
    - State updates have been changed from `postValue()` to direct `.value` assignments, simplifying thread management within the manager which already uses a dedicated coroutine scope.

- **`GeohashBookmarksStore`**:
    - `bookmarks` and `bookmarkNames` are now exposed as `StateFlow` instead of `LiveData`.
    - State updates similarly use `.value` assignment.

- **Nullability**:
    - The non-nullable nature of `StateFlow`'s value reduces the need for null-checks in both the manager classes and their consumers, leading to safer code.

* Refactor: Migrate from LiveData to StateFlow for Nostr components

This commit replaces `LiveData` with `StateFlow` across core Nostr-related classes to align with modern Android architecture and improve state management. This change affects `NostrClient`, `NostrRelayManager`, `LocationNotesManager`, and `GeohashRepository`.

**Key Changes:**

-   **`NostrClient`**:
    -   `isInitialized` and `currentNpub` are now `StateFlow` instead of `LiveData`.
    -   `relayConnectionStatus` and `relayInfo` now return `StateFlow` from `NostrRelayManager`.

-   **`NostrRelayManager`**:
    -   Public properties `relays` and `isConnected` are migrated from `MutableLiveData` to `MutableStateFlow`.
    -   Updates are now pushed using `.value` instead of `.postValue()`.

-   **`LocationNotesManager`**:
    -   All public `LiveData` properties (`notes`, `geohash`, `initialLoadComplete`, `state`, `errorMessage`) are converted to `StateFlow`.
    -   The class documentation is updated to reflect the use of `StateFlow`.

-   **`GeohashRepository`**:
    -   Methods `updateGeohashPeople` and `updateReactiveParticipantCounts` now call `set...` methods on the `state` object instead of `post...`, reflecting the removal of `LiveData` from the underlying state management.

* Refactor: Migrate ChatState from LiveData to StateFlow

This commit refactors the `ChatState`, `ChatViewModel`, and `GeohashViewModel` to use `StateFlow` instead of `LiveData` for managing and exposing UI state. This migration improves state management by leveraging modern coroutine-based flows.

**Key Changes:**

- **`ChatState.kt`**:
    - Replaced all `MutableLiveData` instances with `MutableStateFlow`.
    - Exposed state properties as `StateFlow` instead of `LiveData`.
    - Removed `MediatorLiveData` for computed properties (`hasUnreadChannels`, `hasUnreadPrivateMessages`) and replaced them with `Flow.combine` to create derivative `StateFlows`.
    - Simplified non-nullable `getters` to directly return the `.value` of the `StateFlows`.
    - Removed `postValue` helpers that are no longer necessary.

- **`ChatViewModel.kt`**:
    - Updated all state properties to be `StateFlow`, reflecting the changes in `ChatState`.

- **`GeohashViewModel.kt`**:
    - Changed state properties (`geohashPeople`, `geohashParticipantCounts`, etc.) from `LiveData` to `StateFlow`.
    - Replaced `observeForever` on `LiveData` from `LocationChannelManager` with `viewModelScope.launch` blocks that `.collect()` from the underlying flows.

* Refactor: Migrate UI from LiveData to StateFlow

This commit replaces `LiveData.observeAsState()` with `StateFlow.collectAsState()` across various UI components. This change aligns the codebase with modern Android development practices, using Kotlin Flows for reactive UI state management.

No functional changes are intended. The primary goal is to remove the dependency on `androidx.lifecycle.livedata` from the composable functions.

**Affected Components:**
- `ChatScreen`
- `SidebarComponents`
- `ChatHeader`
- `LocationChannelsSheet`
- `LocationNotesSheet`
- `LocationNotesButton`
- `GeohashPeopleList`
- `LocationNotesSheetPresenter`

* Refactor: Use `collectAsStateWithLifecycle` for UI state collection

* Refactor: move CloseButton to core/ui/component

* Refactor: remove AI generated comments

* Refactor: fix combine to map and use WhileSubscribed

* Refactor: Pass CoroutineScope to ChatState

This commit refactors the `ChatState` class to accept a `CoroutineScope` in its constructor instead of creating its own.

**Key Changes:**

- **`ChatState.kt`**: The constructor now requires a `CoroutineScope`. This scope is used for the `stateIn` operators that convert `Flows` into `StateFlows` (`hasUnreadChannels`, `hasUnreadPrivateMessages`), ensuring they operate within the lifecycle of the provided scope.
- **`ChatViewModel.kt`**: The `viewModelScope` is now passed to the `ChatState` constructor during its instantiation. This ties the lifecycle of the state's coroutines directly to the `ViewModel`'s lifecycle.

* Test: Use `TestScope` for coroutines in `CommandProcessorTest`

This commit refactors `CommandProcessorTest` to use a `TestScope` and `UnconfinedTestDispatcher` for managing coroutines.

This ensures that coroutine-based operations within the test are executed in a controlled and predictable manner, improving test reliability. The `coroutineScope` for `CommandProcessor` and the `scope` for `ChatState` are now both configured to use this test-specific scope.

---------

Co-authored-by: GitHub Action <action@github.com>
2025-12-13 15:58:48 +07:00
Moe Hamade
b2febcee88
feat: add product flavors and TorProvider abstraction (#508)
* feat: add product flavors and TorProvider abstraction

Introduces build flavors to separate Tor functionality from the standard build, reducing APK size for users who don't need Tor.

- Creates `standard` and `tor` product flavors.
- The `standard` flavor is the default, lightweight build.
- The `tor` flavor includes the Arti (Tor) dependency and is identified by the `.tor` application ID suffix.
- Adds a `TorProvider` interface and a `TorProviderFactory` to abstract Tor implementation details between flavors.

Prepares architecture for optional Tor support to reduce APK size
from 142MB to ~4-5MB for standard builds

Related to #454

* Refactor: implement StandardTorProvider and RealTorProvider

This commit refactors the Tor integration by introducing a `TorProvider` interface and creating separate implementations for 'tor' and 'standard' product flavors.

- The original `TorManager` singleton has been moved into `RealTorProvider` for the 'tor' flavor.
- A no-op `StandardTorProvider` is introduced for the 'standard' flavor, which reports Tor as unavailable.
- A `TorProviderFactory` is used to create the appropriate provider at runtime based on the build variant.

* refactor: migrate to TorProvider abstraction

Replaced direct calls to the static `TorManager` with an instance obtained from `TorProviderFactory`.

This change allows for different Tor implementations based on build flavors, improving modularity and abstracting the Tor provider logic.

Updated `BitchatApplication`, `ChatHeader`, `OkHttpProvider`, and `AboutSheet` to use the new factory pattern for accessing Tor functionalities.

* build: add flavor-specific ProGuard rules and CI/CD

 - Split ProGuard rules: base, standard-specific, tor-specific
  - Move Arti/Guardian Project rules to proguard-tor.pro
  - Update CI/CD workflow to build both flavors
  - Add separate artifact uploads for each flavor
  - Add descriptive release notes template

  CI now builds both standard (~4-5MB) and tor (~140MB) APKs."

  resolves #454

* refactor: centralize network reset logic

Extracts the repeated network connection reset logic into a new private function `resetNetworkConnections()`.

This change also replaces direct `_statusFlow.value = ...` assignments with the safer `_statusFlow.update { ... }` function to prevent race conditions.

* ci: run separate build steps for flavors

* feat: disable Tor toggle if not available in build

* ci: parallelize builds and add conditional tor lint

Optimizes CI workflow:
- Parallel matrix builds (4 runners instead of sequential)
- Conditional tor lint only when app/src/tor/ changes in PRs
- Merged test+lint jobs to reduce setup overhead

Reduces CI time by ~50% (8-11 min vs 18-26 min)

* refactor: Unify Tor implementation and remove build flavors

This commit refactors the Tor integration by removing the `standard` and `tor` product flavors in favor of a single, unified build that always includes a custom-built Arti (Tor) library.

Key changes include:
*   **Removed Build Flavors:** Deleted the `standard` and `tor` product flavors from `build.gradle.kts`, simplifying the build process and CI configuration.
*   **Unified Tor Manager:** Replaced the `TorProvider` interface and flavor-specific implementations (`StandardTorProvider`, `RealTorProvider`) with a new singleton, `ArtiTorManager`. This class now manages the Arti lifecycle for all builds.
*   **Custom Arti Wrapper:** Introduced a new `ArtiProxy` class to provide a compatible API wrapper around the custom-built native Arti library (`libarti_android.so`). This replaces the dependency on the external `arti-mobile-ex` library.
*   **Updated Proguard:** Consolidated and updated Proguard rules into the main `proguard-rules.pro` file to keep the necessary `ArtiTorManager` and native library classes.
*   **CI/CD Simplification:** Updated GitHub Actions workflows (`release.yml`, `android-build.yml`) to build and release a single APK instead of separate ones for each flavor.

* build: Configure ABI filters for debug and release builds

For debug builds, include `x86_64` to support emulators.

For release builds, only include `arm64-v8a` to minimize the final APK size.

* feat: Ignore jniLibs directory

This change adds the `app/src/main/jniLibs/` directory to the `.gitignore` file to prevent native libraries from being committed to the repository.

* feat: Refine .gitignore for Arti build artifacts

Improves the `.gitignore` file by:
- Ignoring all `build/` directories except for `tools/arti-build/`.
- Adding specific ignores for Arti build artifacts, including the cloned source repository and the Rust build cache directory.

* feat: Update arti android native library

* feat: add build script and JNI wrapper for Arti

Adds a comprehensive build system for creating custom Arti (Tor in Rust) shared libraries for Android. This replaces the dependency on external, outdated AARs with a fully transparent and reproducible build process.

Key changes:
- Introduces `build-arti.sh`, a script to clone the official Arti repository, apply a JNI wrapper, and build `.so` files for Android.
- Adds `ARTI_VERSION` to pin the build to a specific Arti release (v1.7.0).
- Implements a new Rust JNI wrapper (`src/lib.rs`) that exposes core functions like `initialize`, `startSocksProxy`, and `stop` to the Android app.
- Includes a `Cargo.toml` with release profile optimizations for size (`lto`, `strip`, `opt-level = "z"`).
- Provides detailed documentation in `README.md` explaining the build process, prerequisites, and architecture.

* build script for mac

* consolidate both scripts

* improve script

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2025-12-12 23:27:52 +07:00
Mafiadöner36
a174ac5185
Add Network permission requirement to README (#522)
It forces Network permission prompt right down your throat, so please explain in readme, I was super irritated as its counter intuitive in regards to apps main selling point!
2025-12-12 19:20:09 +07:00
GitHub Action
4511cef619 Automated update of relay data - Sun Dec 7 06:06:39 UTC 2025 2025-12-07 06:06:39 +00:00
GitHub Action
7a628f2fec Automated update of relay data - Sun Nov 30 06:06:53 UTC 2025 2025-11-30 06:06:53 +00:00
GitHub Action
b4142c7f1f Automated update of relay data - Sun Nov 23 06:06:51 UTC 2025 2025-11-23 06:06:51 +00:00
callebtc
04ea2a3162
refactor app constants: service UUID for BLE (#500) 2025-11-18 21:26:20 +01:00
GitHub Action
466c8176a1 Automated update of relay data - Sun Nov 16 06:06:45 UTC 2025 2025-11-16 06:06:45 +00:00
GitHub Action
02cc6301e0 Automated update of relay data - Sun Nov 9 06:06:33 UTC 2025 2025-11-09 06:06:33 +00:00
callebtc
282ac0fe5a
Fix: do not reject empty packets that arent message type (#510)
* do not reject empty packets that arent message type

* leave message with empty byte array

* clean up comments
2025-11-03 20:37:31 +01:00
GitHub Action
cf53c65cfe Automated update of relay data - Sun Nov 2 06:06:38 UTC 2025 2025-11-02 06:06:38 +00:00
callebtc
292ca4532b
fix connection attempt counter tracker (#503) 2025-10-30 13:50:47 +01:00
callebtc
a4ce8cc979
better tracking of first announce seen (#502) 2025-10-30 00:39:24 +01:00
GitHub Action
80d897a022 Automated update of relay data - Sun Oct 26 06:06:36 UTC 2025 2025-10-26 06:06:36 +00:00
callebtc
c3f5739fea
refactor app constants: service UUID for BLE (#494) 2025-10-22 13:48:35 +02:00
callebtc
da7fdd0f23
suppress relay of reassambled packet by setting TTL=0 (#492) 2025-10-21 12:52:14 +02:00
Developer Chunk
9535ca8940
feat: Add tablet landscape orientation support (#490)
- Tablets now support landscape mode, phones remain portrait-only
- Add OrientationAwareActivity base class for orientation management
- Add DeviceUtils.isTablet() for runtime device detection
- Update MainActivity and GeohashPickerActivity to extend OrientationAwareActivity
- Uses multiple detection criteria (screen size, density, configuration)
Fixes #480
2025-10-20 20:14:50 +02:00
callebtc
f633509848
bump to 1.5.1 (#488) 2025-10-19 12:43:55 +02:00
callebtc
59ee530d54
show media buttons only in mesh (#487) 2025-10-19 12:32:56 +02:00
GitHub Action
7881210785 Automated update of relay data - Sun Oct 19 06:06:46 UTC 2025 2025-10-19 06:06:46 +00:00
callebtc
2869a2b192
actually 1.5.0 (#485) 2025-10-18 15:03:45 +02:00
callebtc
20342351a5
Camera take picture (#484)
* camera capture

* icon change

* image preview great

* remove string
2025-10-18 15:03:32 +02:00
callebtc
af4dd175c4
lower voice quality, smaller file size (#483) 2025-10-18 14:20:35 +02:00
callebtc
f99cb46b26
Location notes (#482)
* try 1

* works

* equalize UI with ios

* ui cleanup

* geohash chats: no building

* load notes in background

* insta

* simplify and tor icon change

* icons nice

* refactor

* unify location enabled / disabled

* cooler

* simplify, doesnt subscribe right away

* load when clicked

* plus minus location notes

* load when tor is available

* translations

* fix transalations

* implement review comments
2025-10-18 14:19:53 +02:00
callebtc
e8862d5128
versioncode 24 (#479) 2025-10-16 14:26:13 +02:00
callebtc
c4966b9a28
remove from all languages (#478) 2025-10-16 14:20:35 +02:00
Developer Chunk
fc3a00b4b9
fix: Resolve debug settings bottom sheet crash on some devices (#474)
Fixes #472 - App crashing in debug settings
The issue was in ui/debug/DebugSettingsSheet.kt:
- Line 304: Code was pre-formatting double value with String.format() then passing
  to string resource that expected raw double parameter
- Line 307: Numeric values weren't properly converted to strings for string resource
  that expected string parameters
Changes made:
- Changed stringResource(R.string.debug_target_fpr_fmt, String.format("%.2f", gcsFpr))
  to stringResource(R.string.debug_target_fpr_fmt, gcsFpr) - passes raw double value
- Changed stringResource(R.string.debug_derived_p_fmt, p, nmax)
  to stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()) -
  properly converts numeric values to strings
This resolves the IllegalFormatConversionException: f != java.lang.String crash
when scrolling through the debug settings bottom sheet.
2025-10-16 13:21:44 +02:00
callebtc
4a6fe922f3
bump version 1.4.0 (#470) 2025-10-14 13:02:12 +02:00
callebtc
1486121b77
Extract constants (#469)
* extract constants

* refactor constants
2025-10-12 20:59:37 +02:00
callebtc
c61347defe
Remove ghost sync (#468)
* delete stale peers and messages from sync manager

* ignore old announcenements
2025-10-12 19:41:44 +02:00
callebtc
ad28cc710c
Translations (#467)
* english done

* de

* more extraction

* wip strings en

* translations work

* remove unneeded translations

* remove notification message

* add languages

* new languages
2025-10-12 18:54:20 +02:00
GitHub Action
3b2241e891 Automated update of relay data - Sun Oct 12 06:06:18 UTC 2025 2025-10-12 06:06:18 +00:00
elian1780
4030396203
fix: clear channel messages on reset (#446)
When `clearMessages` is called, it now also clears the `channelMessages` in the state, ensuring that messages from specific channels are also removed.

Co-authored-by: elian.kumaraku <elian.kumaraku@creactives.com>
2025-10-11 21:31:35 +02:00
GitHub Action
7e65450eac Automated update of relay data - Sun Oct 5 06:06:17 UTC 2025 2025-10-05 06:06:17 +00:00
GitHub Action
6546943cf7 Automated update of relay data - Sun Sep 28 06:06:42 UTC 2025 2025-09-28 06:06:42 +00:00
GitHub Action
2e4b0b04ed Automated update of relay data - Sun Sep 21 06:06:38 UTC 2025 2025-09-21 06:06:38 +00:00
callebtc
2b0bb5af74
fix unit tests (#442) 2025-09-20 00:05:46 +02:00
callebtc
633a506753
Media transfers (#440)
* tor voice wip

* worky BLE a bit

* can send sound

* remove tor

* ui cleanup

* recording time

* progress bar color

* nicknames for audio

* onboarding permissions no microphone

* may work

* fix destionation

* extend

* refactor voice input component

* fix keyboard collapse issue

* send images

* wip image open

* image sending works

* wip waveforms

* better

* better animation

* fix cursor for sending audio

* image sending animation

* image sending animation

* full screen image viewer

* gossip sync for fragments too

* reduce delays

* fix keyboard focus

* use v2 for file transfers

* do not sync fragments

* scrollable image viewer

* ui

* ui adjustments

* nicer animation

* seek through audio

* add spec

* add more details to documentation

* File sharing E2E:
- Add TLV BitchatFilePacket, FileSharingManager
- Implement sendFileNote in ChatViewModel
- File receive path: save to files/incoming and render [file] messages with FileMessageItem or FileSendingAnimation during transfer
- SAF FilePickerButton and dispatcher wiring; image/file choice to follow in MediaPickerOptions
- Add FileViewerDialog with system open/save, FileProvider and file_paths
- Hook transfer progress to file sending UI
- Manifest: READ_MEDIA_* and FileProvider
- Fix MessageHandler saving and prefix for non-image payloads
- Add helper utils (FileUtils)

* kinda wip

* fix buttons

* files half working

* wip file transfer

* file packet has 2-byte TLV and chunks. it wokrs but it sucks

* clean

* remove gossip sync for fragments

* fix audio and image rendering

* adjust FILE_SIZE TLV size too

* cleanup

* haptic

* private messages media

* read receipts for media

* use enum for message type not string

* delivery ack checks dont push content

* check

* animation fix

* refactor

* ui adjustments

* comments

* refactor

* fix crash on send and receive of the same file

* refactor notifications

* tests
2025-09-19 22:46:14 +02:00
callebtc
1178fc254a
remove the noise handshake if peer goes offline (#435) 2025-09-15 15:33:39 +02:00
callebtc
7061a96cce
bump to 1.3.1 (#432) 2025-09-15 12:43:58 +02:00
callebtc
4eda850110
better verbose logging (#431) 2025-09-15 01:31:32 +02:00
callebtc
c2609643da
add github tests (#233)
* add github tests

* fix github tests (#409)

---------

Co-authored-by: Tobiloba Oyelekan <miketobi73@gmail.com>
2025-09-14 17:31:19 +02:00
callebtc
277dbdf4e1
nostr: fix block feature (#427)
* fix block feature

* fix nostr nickname in sidebar
2025-09-14 17:30:47 +02:00
lollerfirst
a4ef2ef29c
fix: bookmark removal persistence (#424) 2025-09-14 16:38:39 +02:00
callebtc
9c7567e62e
lock to tor (#426)
* lock to tor

* bootstrap state
2025-09-14 16:36:02 +02:00
GitHub Action
df4aa4b25d Automated update of relay data - Sun Sep 14 06:06:21 UTC 2025 2025-09-14 06:06:21 +00:00
callebtc
382d29e03f
bump versioncode (#419) 2025-09-14 05:12:42 +02:00
callebtc
4786b1f250
default pow to 12 (#418) 2025-09-14 05:04:04 +02:00
callebtc
b96bf180f9
Fix pow (#417)
* fix pow

* only pow with nonse
2025-09-14 05:01:34 +02:00
callebtc
deba156711
bumo to 1.3.0 (#416) 2025-09-14 04:26:04 +02:00
callebtc
1a398b16ef
Redesign: Permissions screen, battery optimization, location sheet (#415)
* update permissions screen design

* battery optimization screen

* remove init screen

* skip init screen on load

* skip on load only

* location sheet design wip

* denser location sheet

* location sheet layout fix
2025-09-14 04:22:56 +02:00
callebtc
861eaaeaef
catch errors (#397) 2025-09-14 03:50:51 +02:00
callebtc
96d59d55ff
tor on by default (#414) 2025-09-14 03:50:22 +02:00
lollerfirst
779717217f
feat: geohash bookmarks (iOS parity) (#410)
* Geohash bookmarks (iOS parity):
- Add GeohashBookmarksStore (persist bookmarks + friendly names)
- Integrate bookmark toggle into LocationChannelsSheet (nearby + bookmarked sections)
- Add header toggle for current geohash bookmark
- Sample counts for union of nearby + bookmarks while sheet open
- Add Geohash.decodeToBounds() to support friendly name resolution
- Fix coroutine usage and Gson type inference issues

* fix UI

* clear bookmarks on triple tap

* adjust icons

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2025-09-14 03:46:37 +02:00
ruslan_yet
eba0d36015
Add App Info Bottom Sheet (#406)
* Automated update of relay data - Sun Sep  7 06:20:11 UTC 2025

* Refactor(ui): Improve AboutSheet design and layout Ios Like

This commit refactors the `AboutSheet` composable with several UI enhancements:

-   Updated the overall layout and styling for a more modern look and feel.
-   Introduced a collapsing top bar effect on scroll.
-   Replaced `FeatureCard` with individual `Row` layouts for feature descriptions.
-   Adjusted typography, spacing, and padding for better readability.
-   Updated color usage to align with `MaterialTheme.colorScheme`.
-   Made the bottom sheet skip the partially expanded state by default.
-   Added `statusBarsPadding` to the `ModalBottomSheet`.

---------

Co-authored-by: GitHub Action <action@github.com>
2025-09-14 03:37:53 +02:00
callebtc
73c91b9509
Plumtree sync (#393)
* wip plumtree

* sync works

* fix logging

* ttl to 0

* fix send packet to one peer

* spec

* wip GCS instead of bloom

* remove bloom filter remainders

* clean

* prune old announcements

* remove announcements from sync after LEAVE

* sync after 1 second

* pruning

* track own announcement and prune messages without announcements

* fix pruning

* getGcsMaxFilterBytes default value 400 bytes

* parameters
2025-09-14 03:32:10 +02:00
callebtc
3967ef8922
peerid computed from noise fingerprint (#413) 2025-09-13 16:43:06 +02:00
Yash Bhutwala
ad77fd38c5
Nostr DMs: use relay createdAt for display; keep arrival order for sorting; remove comments (#396)
Coalesces commits:
- Nostr DMs: use relay createdAt for display; keep arrival order for sorting
- remove comments as requested by calle
2025-09-11 13:20:53 +02:00
Tobiloba Oyelekan
63faf4cceb
fix truncated list in sidebar (#405) 2025-09-11 13:02:50 +02:00
Tobiloba Oyelekan
97c301f510
add playstore link to readme (#400) 2025-09-09 23:39:12 +02:00
callebtc
c1e56188d6
Revert "Mesh gossip (#381)" (#394)
This reverts commit 0969c0641eeb272462ef0d5c21f7b0b3cd6b8bea.
2025-09-08 15:15:54 +02:00
callebtc
0969c0641e
Mesh gossip (#381)
* wip mesh graph

* gossip fix

* gossip works

* source-based routing wip

* log
2025-09-08 15:15:32 +02:00
callebtc
bea1bbf1a8
add missing file (#392) 2025-09-08 14:28:30 +02:00
callebtc
998ee606b1
Nostr refactor simplify (#390)
* fix bug

* geoDM receive works, send doesnt, and incoming message doesnt make sender appear in peer list

* fix nostr dm

* geohash dms work

* Geohash DM UI: stop mixing Nostr DM temp chats into mesh offline list; ensure geohash DM senders are added to geohash people list only. Removed nostr_* sidebar append in PeopleSection; kept 64-hex mesh offline favorites. Verified build.

* refactor

* nice

* works

* merging nostr -> mesh works

* tripple click to delete all

* fix sidebar icon

* remove hash

* dms have correct recipient

* works

* wip unread badge

* geohash dms wip

* dms work
2025-09-08 13:46:15 +02:00
callebtc
ba518269b4
fix geohash livedata wiring (#389) 2025-09-07 09:04:21 +02:00
callebtc
6b54c70d26
fix remove peer on disconnect (#388) 2025-09-07 08:31:23 +02:00
GitHub Action
c20e9defde Automated update of relay data - Sun Sep 7 06:05:54 UTC 2025 2025-09-07 06:05:54 +00:00
callebtc
9b8f98ec7c
bump version 1.2.3 (#384) 2025-09-06 14:49:00 +02:00
callebtc
b131554efe
fix some debug settings (#383) 2025-09-06 14:45:48 +02:00
lollerfirst
a804782476
feat(geohash): add in-app Geohash Picker (#363)
* feat(geohash): add in-app Geohash Picker map with quadrant drill-down and Activity integration

- New GeohashPickerActivity hosting a WebView with Leaflet-based picker
- Adds map icon next to custom geohash input in LocationChannelsSheet
- Picker allows quadrant selection and subquadrant drill-down; returns selected geohash
- Register activity in AndroidManifest

perf(picker): improve map performance and correctness
- Use LayerGroup and clearLayers() on redraw to avoid stale overlays
- Use Leaflet canvas renderer for rectangles to reduce DOM and improve perf
- Make labels non-interactive and pointer-events: none to avoid gesture overhead
- Fix initial mega-label artifact by grouping and clearing labels with cells

chore: add asset geohash_picker.html with minimal geohash helpers (bounds/encode/adjacent)

* remove accidentally committed submodule.

* fix some issues

* better geohash labels on map

* design wip

* countries green

* wip

* geohashpicker: pan + zoom; start from current geohash channel

* better ui

* ui elements

* nice

* readability

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2025-09-06 11:49:23 +02:00
callebtc
cc45f477fb
init noise handshake on queue DMs (#379)
* init noise handshake on queue DMs

* establish noise more aggressively
2025-09-06 10:58:39 +02:00
callebtc
9518d386d0
better tor management (#380)
* better tor management

* better tracking
2025-09-06 10:41:51 +02:00
lollerfirst
4b81b7f97a
fix: action message routing and duplicate local echo in geohash channels (#374)
* fix: send action messages to correct chat

* fix(action): avoid double local echo for action messages in geohash channels

When sending /hug or /slap in a location (geohash) channel, CommandProcessor
was adding a local echo and then routing to NostrGeohashService, which also
adds a local echo. This resulted in duplicate action messages on the sender’s
screen.

Change: detect when the selectedLocationChannel is a Location and, in that
case, skip adding the local echo in CommandProcessor and only call the
onSendMessage transport callback (NostrGeohashService will add the echo with
proper metadata). Private and mesh (non-location) behavior is unchanged.
2025-09-05 15:41:02 +02:00
callebtc
f47819a31e
Debug button (#377)
* feat(debug): add DebugSettingsManager + DebugSettingsSheet scaffold and AboutSheet entry; groundwork for verbose logging, GATT controls, relay stats, device/scan views

* fix(debug): wire debug sheet launch via onShowDebug in AboutSheet from ChatScreen; add public start/stop server/client methods}

* fix(about): remove misplaced debug item block inside PoW section; keep debug launcher managed by ChatScreen only

* feat(debug): wire DebugSettingsSheet role switches to explicit startServer/stopServer and startClient/stopClient; expose role controls + disconnectAll on BluetoothConnectionManager; fix syntax error; build passes

* feat(debug): add connect/disconnect helpers; wire DebugSettingsSheet connect/disconnect actions to BluetoothConnectionManager

* feat(debug): add debug settings button at bottom of AboutSheet; wire onShowDebug callback to open DebugSettingsSheet from ChatScreen

* feat(debug): wire verbose logging into chat view via DebugSettingsManager; add rolling relay stats; push connected devices and scan results; ensure GATT role stop closes connections; log incoming packets and relay events; add UI polling for devices; build passes

* chore(debug-branch): remove unrelated files that were mistakenly added in first commit; keep only intended debug settings changes

* fix(chat): prevent mesh→geohash leak

Root cause:
- DebugSettingsManager→chat bridge appended system logs to the global timeline regardless of active channel
- MeshDelegateHandler added public mesh messages to UI unconditionally

Fixes:
- Only inject debug system messages when selected location channel is Mesh
- Only add public mesh messages to UI when Mesh is selected (still send notifications)

Build: ./gradlew assembleDebug
}

* fix(timeline): restore message persistence across channel switches

Root cause:
- NostrGeohashService.switchLocationChannel() called messageManager.clearMessages()
  which wiped ALL messages (mesh + debug + geohash) from main timeline
- Geohash events were adding to main timeline, causing cross-contamination

Fixes:
- Remove messageManager.clearMessages() from channel switching
- ChatScreen displayMessages now routes to separate storage:
  - Mesh: messages (main timeline, includes debug logs when Mesh selected)
  - Geohash: viewModel.getGeohashMessages(geohash) from separate history
  - Private: privateChats[peerID]
  - Channels: channelMessages[channel]
- Geohash events no longer add to main timeline, only to geohash history
- Mesh messages always stored to preserve history when switching away

Result: Each chat type has persistent separate storage, no message loss

* Debug/relay logs: use MessageType names; include device route in verbose packet logs.

* Verbose device-peer assignment and connection/disconnection logs with peerId, deviceId, nickname; packet relay log uses MessageType names.}

* Debug logs API: include nickname and deviceId for incoming/relay; update callers.}

* Fix compile: import MessageType; correct debug manager API usage and remove stray insertion; build.}

* Respect relay toggle; helper to log relay with deviceId.}

* PacketRelayManager: add relay toggle and unified logging helper; clean file header comments; build fixes.}

* changes

* Geohash local echo: do not add to mesh timeline; rely on geohash history and ChatScreen display for location channels.

* persisting

* persist debug settings

* gitignore

* gatt server / client controls

* debugger

* max connection settings and graph

* more graph

* better logging

* refactor logging
2025-09-05 15:40:39 +02:00
callebtc
91f3f270d4
Fix geohash filtering (#376)
* fix(geohash): enforce client-side filter on subscription and validate 'g' tag in handler; move dedup after validation; avoid misrouting events across geohashes

* cleanup duplicate code block

* remove
2025-09-05 13:26:38 +02:00
callebtc
905ccf5f17
better tor management (#373) 2025-09-03 02:22:27 +02:00
callebtc
8b3dc71dc6
Refactors cleanup (#372)
* cleanup peermanager

* cleanup geohash code

* direct connections fix

* pow display

* track disconnects too

* display pow only if enabled

* display pow only if enabled

* direct connection tracking
2025-09-02 22:09:44 +02:00
Héctor de Isidro
bbf5918896
Fix edge-to-edge layout issues (#367)
* Remove redundant setDecorFitsSystemWindows call

* Fix edge-to-edge layout issues
2025-09-02 14:25:45 +02:00
Héctor de Isidro
e380408b28
Fix system bar colors for dark theme (#368)
* Add computed properties for ThemePreference enum

* Fix system bar colors for dark theme
2025-09-02 14:24:26 +02:00
Minh
8a55414143
forcing all commands to lower case (#369) 2025-09-02 13:51:26 +02:00
callebtc
b62b15a21f
Nip13 pow (#357)
* add pow

* animation

* matrix style

* animation better

* improve animation

* improve animation

* works

* fix jump

* difficulty indicator

* 10 is default

* animation runs forever

* pow in message timestamp

* pow works

* default on

* adjust animation

* no printing
2025-08-31 13:23:22 +02:00
GitHub Action
5b62118336 Automated update of relay data - Sun Aug 31 06:06:12 UTC 2025 2025-08-31 06:06:12 +00:00
callebtc
6b77eb93c1
move tor icon (#355) 2025-08-30 13:57:50 +02:00
callebtc
202c8edc53
bump version (#354) 2025-08-30 13:47:22 +02:00
callebtc
33b5814b7a
Sign mesh message (#353)
* sign BLE packets

* fix x
2025-08-30 12:45:36 +02:00
callebtc
cedc6552ce
make notifications optional (#349) 2025-08-29 23:39:11 +02:00
lollerfirst
926dfe3cf0
feat: update bundled relays weekly (#312)
* update bundled relays weekly

* fix error
2025-08-29 23:38:52 +02:00
callebtc
0b44b850f2
restore default (#348) 2025-08-29 22:11:05 +02:00
callebtc
0aecaf50f8
Manually disable location (#347)
* manually disable location, design needs to be fixed

* fix ui
2025-08-29 22:09:44 +02:00
callebtc
b1234ff548
simplify (#346) 2025-08-29 21:26:21 +02:00
callebtc
85ddf3ca38
retry on bind error (#345) 2025-08-29 21:10:39 +02:00
callebtc
3248f37932
update (#340) 2025-08-29 20:13:11 +02:00
callebtc
4c7786a0d9
proguard arti (#344) 2025-08-29 20:12:35 +02:00
callebtc
686e2e78ec
Bundle tor (#339)
* tor started

* tor works

* tor code

* improve manager

* works

* move tor icon

* werks

* arti works

* arti works

* arti works with reconnect

* delay fix

* refactor
2025-08-29 14:37:35 +02:00
callebtc
7f4bd96739
fix missing file (#335) 2025-08-29 11:16:56 +02:00
callebtc
63d6649ab4
bump to 1.2.1 (#332) 2025-08-29 00:50:53 +02:00
callebtc
2ec3141431
render links normally (#331) 2025-08-29 00:40:25 +02:00
callebtc
9c103180cd
limit nick length (#330) 2025-08-29 00:27:55 +02:00
callebtc
550520795f
UI geohash notifications (#325)
* location name in notifications

* remove tests

* panic nostr

* mentions with hashes, otherwise none

* fix timestamps

* parse geohashes in messages

* works

* fix country name

* mention notifications work
2025-08-28 17:47:47 +02:00
2014
3ea2aed9a4
fixed bulletpoint (#272) 2025-08-28 09:18:07 +02:00
Minh
02d5466812
Add active peer notification (#273)
* adding notification for active peers + tests

* adding a recently seen peer set to track if we've seen that peer before

* changing back to notificationManager naming

* fixing some weird formatting that occurred during merge conflict fix
2025-08-28 09:17:41 +02:00
lollerfirst
28abd3c593
allow user to select a theme preference over light/dark/system (#318) 2025-08-28 08:14:46 +02:00
mario7421
846cd976c0
Bug fix: the app should show the correct geohash also if a location is not yet known to the system. Remove the default location in San Francisco and show a spinner until the correct position becomes available. (#308) 2025-08-28 08:10:48 +02:00
callebtc
6fe6e049ad
Nip17 dms to extend the mesh (#313)
* favorite each other

* show favorites as system messsages

* wip show glove when offline but mayb edoesnt work

* send and receive works

* wip kinda works but no

* getting there

* kinda

* show offline peers in peer list

* kinda wonky but almost works

* fixes

* nostr user goes offline works

* nostr -> mesh works

* handoff works

* background message processing

* read works

* seen message store was missing
2025-08-25 18:38:10 +02:00
callebtc
941be1b98f bump to 1.2.0 2025-08-25 10:20:18 +02:00
shroominic
47c40fb9f3
fix nip-17 (#305)
* NIP-59 stuff

* nip-17 ios to androing works, android to ios still wip

* fix impl

* ios compat

* default relays for DMs

* delivery ack works

* delivery ack

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2025-08-24 19:06:29 +02:00
callebtc
23c397fe7b
Geohash-specific relays (#306)
* per-relay-chat

* normalize relay URL

* turn off default relays

* add relay list

* fetch relay list from the internet
2025-08-24 12:52:31 +02:00
callebtc
cb7b01ad81
fix teleport tag and icons (#303) 2025-08-24 02:03:00 +02:00
callebtc
37c8c79310
UI changes and fixes (#301)
* country -> region, region –> province

* revise clicks

* fix longpress for mention etc

* scroll down button wip

* fix distance

* fix button

* fix header icon
2025-08-23 21:18:36 +02:00
callebtc
7106be4b07
country -> region, region –> province (#298) 2025-08-23 15:44:46 +02:00
callebtc
a7604d9026
slightly increase padding (#295)
* slightly increase padding

* better colors

* add copy to bottom sheet

* increase font size

* base font size

* anchor chat at the bottom
2025-08-23 14:14:42 +02:00
callebtc
26520991cf
New about sheet (#294)
* about sheet

* version

* about page
2025-08-23 12:04:21 +02:00
callebtc
e92266c025
bump version (#293) 2025-08-23 11:52:06 +02:00
callebtc
16c55a4105
Statusbar seamless (#289)
* wip status bar

* header fix
2025-08-23 02:36:36 +02:00
callebtc
f13f3063c9
Geohash notifications (#288)
* mention notifications

* notifications

* warning
2025-08-23 01:35:46 +02:00
callebtc
65afdfb92e
Link preview (#286)
* link preview

* link previews
2025-08-23 00:38:33 +02:00
callebtc
49c42ba169
user bottom sheet (#285)
* wip sheet

* longpress

* block wip

* blocking works
2025-08-23 00:26:18 +02:00
callebtc
c9c02d993e
persist last channel (#284) 2025-08-22 23:45:51 +02:00
callebtc
fa2e3fa0b2
bump version code (#283) 2025-08-22 22:36:23 +02:00
callebtc
2458b471ed
UI fixes 2 (#282)
* better icon

* scroll

* scroll bottom sheet
2025-08-22 22:27:35 +02:00
callebtc
8e61ab24bf
Scroll fix (#281)
* auto scroll

* scroll fix
2025-08-22 22:01:11 +02:00
callebtc
440c73961e
fix minify errors (#280) 2025-08-22 21:05:03 +02:00
callebtc
d3e2dce27b
bump version (#277) 2025-08-22 19:10:12 +02:00
callebtc
7243d841a3
Nostr geohash (#276)
* first nostr build

* add test file

* internet access

* fix relay manager

* fix serialization

* demo service - remove later

* fix nostr

* event dedupe

* dedupe

* ui wip

* can send messages

* subscription works

* works

* favs

* works

* delete chat on change

* fix mentions

* remove autojoin channels

* styling

* adjust colors

* ui changes

* live updates working

* use local timestamp

* message history in background

* robust

* fixes

* nicknames refresh optimization

* nostr service

* refactor nostr

* style

* geohash works

* centralize colors

* refactoring

* disable DMs for now: click on peer nickname doesnt open chat list in geohash mode

* use local time

* less logging

* robustness

* scroll nickname

* adjust some text
2025-08-22 19:09:18 +02:00
callebtc
848cffee07
bump (#274) 2025-08-22 08:31:35 +02:00
callebtc
98706acfa5
change app ID to com.bitchat.droid (#270) 2025-08-21 00:08:43 +02:00
callebtc
b4f080ff32
bump to 0.9 (#269) 2025-08-20 23:54:10 +02:00
callebtc
c5a3368b9f
Sign announcements (#267)
* wip

* announcements WIP

* works

* restore mainnet
2025-08-20 13:24:52 +02:00
callebtc
4acfafb998
fix fragmentation baby (#266) 2025-08-19 11:16:23 +02:00
callebtc
9795e2ce8a
Changes bitchat protocol (#265)
* create payload

* compiles and can send messages

* identityannouncement

* DMs work, read receipt not sent yet

* works

* delete old code

* simplify

* working

* fragment wip

* compression wip

* use zlib compression

* clean

* nice

* mesh

* remove comments
2025-08-18 21:16:27 +02:00
Moritz Warning
b86f2cdb11
gradle: disable baseline profile (#260)
Needed as a workaround for reproducible builds
by F-Droid until the baseline.prof is reproducible.
2025-08-16 15:32:05 +02:00
lollerfirst
72af724588
add sendPeriodicBroadcastAnnounce to startServices (#243) 2025-08-11 19:45:15 +02:00
Tair
b86a6bf4d1
Fix favorite icon not updating on first Sidebar open (#237) 2025-08-07 15:07:45 +03:00
Shubert Munthali
763d290055
Mock Android Log to fix PeerManager tests (#239) 2025-08-07 14:39:08 +03:00
callebtc
b34ef896c7
0.8.1 (#242) 2025-08-07 12:14:04 +03:00
callebtc
67c91b0559
remove system messages for connect / disconnect (#241)
* remove system messages for connect / disconnect

* fix tests
2025-08-07 11:58:06 +03:00
Héctor de Isidro
b418aa3899
Add CommandSuggestionsBox quick access (#146)
* Add CommandSuggestionsBox quick access

* Remove unnecessary evaluation

* Use a FilledTonalIconButton for the CommandSuggestionsBox quick access button

* fix import

* quick command instead of send

* simplify

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2025-08-06 01:25:15 +02:00
Moritz Warning
1d1a91108e
f-droid: add metadata folder (#134)
* add fastlane metadata for f-droid and gplay

* remove google signing block

Needed to be removed for F-Droid.
See https://gitlab.com/fdroid/admin/-/issues/367
2025-08-06 01:08:16 +02:00
Tair
8bf2220cea
Make Sidebar react to the nickname and RSSI changes. (#228) 2025-08-06 00:06:47 +02:00
Shubert Munthali
62e1b752af
Add PeerManager unit tests (#171)
* Add PeerManager unit tests

* Improve PeerManager tests

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2025-08-05 23:51:34 +02:00
Eben Justice
a0d1c6f77c
Add Real-Time Bluetooth State Monitoring to OnboardingFlowScreen (#55)
* Add bluetooth monitor function to onboarding flow screen

* Adds function that checks bluetooth status in real time

* Add bluetooth monitor function to onboarding flow screen

* Refactor

* Comment button to manually check BT state

* Original commits

* feat(ui): Remove auto-trigger for Bluetooth enable prompt

* feat(ui): Remove auto-trigger for Bluetooth enable prompt

* feat(ui): Remove BluetoothStatusBanner. Pending approval on feature, will add in a new PR

* Refactor and fix conflicts

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2025-08-05 23:49:04 +02:00
Tobiloba Oyelekan
335f914f32
Add MainViewModelTest (#163) 2025-08-05 23:45:05 +02:00
Moe Hamade
32c503527b
disabled ripple effect from grey overlay in SidebarOverlay (#167)
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
2025-08-05 23:40:43 +02:00
Tobiloba Oyelekan
bff8b1112c
Disable overlay bakground animation (#170)
* remove slider background from animation, show only when slider is shown

* cleanup unused import in chatscreen
2025-08-05 23:38:16 +02:00
Francisco Hola
325ce1730d
Feature/mentions (#197)
* mention initial implementation

* Message conservation
2025-08-05 23:32:16 +02:00
Savio
7ee0e8f2f4
Fix link to GitHub Discussions (#208) 2025-08-05 23:27:35 +02:00
callebtc
ee7bd3e776 google play 2025-08-04 13:50:03 +02:00
Welisson Lima
c92d2d28b5
fix(ui): prevent sender color of past messages (#201) 2025-07-31 07:04:18 +02:00
callebtc
9025aae3d1
bump to 0.8.1 (#206) 2025-07-31 06:53:40 +02:00
callebtc
4d0419c536
Touches (#205)
* touch

* increase TTL of announcements

* ui touches
2025-07-31 06:28:31 +02:00
callebtc
4cb5932fd0
improve identity announcements (#198)
* improve identity announcements

* touches

* signing noise id

* panic mode delete signing key
2025-07-31 06:26:41 +02:00
635 changed files with 128941 additions and 6572 deletions

View File

@ -0,0 +1,260 @@
---
name: android-readme-screenshot-studio
description: Create or refresh polished, high-resolution screenshots of the Bitchat Android app for README and repository showcase use. Use this skill whenever a user asks for README screenshots, app-store-like repository images, a populated mesh-chat showcase, voice-note or media conversation captures, a geohash globe image, higher-resolution Android emulator captures, or a PR that adds or replaces documentation screenshots. It owns the complete workflow from latest-main isolation and deterministic synthetic fixtures through real app rendering, visual inspection, system-chrome cropping, README asset updates, clean builds, and an optional PR. Do not use it for before/after UI regression evidence, which belongs to android-ui-visual-review, or for physical mesh behavior, which belongs to mesh-lab.
compatibility: Requires git, gh, the Android SDK and emulator, adb, Java/Gradle, Python 3, and image inspection support. FFmpeg is useful for capture-only media preparation.
---
# Android README Screenshot Studio
Create repository screenshots from the real Bitchat Android UI, not from a
drawn mockup. The result should look intentional enough for the top of the
README while remaining reproducible, synthetic, and honest about what a static
emulator capture proves.
## Resolve the brief
Extract as much as possible from the conversation before asking questions.
Confirm or infer:
1. Which surfaces are needed, such as mesh chat and geohash globe.
2. The exact visible state and ordering. Treat phrases like “photo, messages,
three voices, thumbs-up” as a chronological contract rather than a loose
suggestion.
3. Whether existing README images should be preserved, replaced, or added.
4. The target base. Default repository work to the latest `origin/main`.
5. Whether GitHub publication and merge are authorized. A request to “open a
PR and merge it” authorizes both; otherwise do not merge.
Do not invent the subject of a requested photo. Nicknames, channel names, or
previous fixture copy are not sufficient justification for choosing an outdoor,
urban, political, or personal scene. Reuse an existing rights-safe asset when
the subject should remain stable, or ask for the intended subject. If the user
explicitly approves synthetic imagery, disclose it and keep its source
capture-only unless they request a committed asset.
Read [references/showcase-recipes.md](references/showcase-recipes.md) for every
run. It contains the concrete mesh-chat and globe recipes, framing guidance,
the verified current-pair fast path, and the final acceptance checklist. When
the request matches the existing README pair and the production UI has not
materially changed, try that fast path first and then validate every visible
result. Fall back to tracing the current implementation when an entry point,
state model, or composition has changed.
When a populated screen requires a debug fixture, also read
[../android-ui-visual-review/references/fixture-recipes.md](../android-ui-visual-review/references/fixture-recipes.md).
## Work in a fresh tree
Protect the user's active checkout:
1. Inspect `git status` without modifying it.
2. Fetch `origin/main`.
3. Create a new `codex/` branch in a temporary worktree rooted at the current
`origin/main`.
4. Keep screenshots and raw captures in a separate temporary artifact
directory so cleanup or checkout operations cannot remove them.
Do not switch the user's original checkout, reuse a dirty branch, or mix an
unrelated PR into the screenshot change. If the request continues an existing
screenshot PR, reuse its already-isolated worktree only after verifying its
head and base.
## Establish the capture contract
Before building, write a compact local matrix containing:
- surface and navigation path;
- chronological fixture contents;
- expected visible top and bottom rows;
- emulator profile class, portrait orientation, theme, and locale;
- crop policy and final asset dimensions;
- existing asset path and README reference;
- behaviors the static screenshot does not prove.
Use the current production UI and latest `main` interaction model. Trace the
screen entry point and state source before adding a fixture. A beautiful capture
of a stale or fake UI is not acceptable.
## Use a high-resolution Android canvas
Prefer the newest stable Android runtime installed locally and a large,
high-density portrait emulator profile. Reuse a previously validated capture
profile when it remains available, but verify the guest properties and keep
the resulting profile facts in the local capture contract rather than the
repository or GitHub text.
After boot, record the guest values with an explicit emulator selector:
```sh
adb -s "$ANDROID_README_SERIAL" shell getprop ro.build.version.release
adb -s "$ANDROID_README_SERIAL" shell getprop ro.build.version.sdk
adb -s "$ANDROID_README_SERIAL" shell getprop ro.build.version.security_patch
adb -s "$ANDROID_README_SERIAL" shell wm size
adb -s "$ANDROID_README_SERIAL" shell wm density
```
Never publish emulator selectors, AVD names, local paths, usernames, IP
addresses, or other machine identifiers.
## Build a deterministic showcase fixture
Launch the Activity before injecting process-local state. Prefer existing debug
hooks. If they cannot express the composition, add the smallest temporary
command under:
```text
app/src/debug/java/com/bitchat/android/testhook/
```
The fixture should:
- use synthetic names, peer IDs, message IDs, and copy;
- use the real local mesh peer ID for self-authored messages;
- use a fixed epoch so timestamps and ordering are stable;
- insert records in the exact requested chronology;
- report structured counts through the test-hook result file;
- copy capture-only media into the app's cache or files directory;
- populate only the peers and state needed for the header;
- avoid persistence unless persistence itself is the subject.
For voice notes, route real audio files through the app's waveform extractor.
Use short, distinct, locally synthesized speech clips or other rights-safe
speech audio. Never draw a decorative waveform and call it speech. Wait for
asynchronous decoding before capture, then inspect that pauses and syllable
envelopes look plausibly different between notes.
For image attachments, use a rights-safe existing asset or an explicitly
approved synthetic source. Keep fixture media outside production source sets
and remove every capture-only hook before committing. Compose image rows may
remember a decoded bitmap by file path. After replacing the bytes at an
unchanged path, relaunch the app or use a new destination path before judging
the revised crop.
## Capture from the real app
Build and install the ABI-matching debug APK, satisfy onboarding and
permissions, inject the fixture, and navigate to the intended surface.
Capture directly:
```sh
adb -s "$ANDROID_README_SERIAL" exec-out screencap -p > "$ARTIFACT_PATH"
```
Inspect the full screenshot immediately. Check message count and order,
nickname ownership, peer count, waveform variety, image visibility, globe
center, grid precision, clipping, and composer placement.
Inject a complete timeline in one operation and allow at least two seconds of
quiet UI time after the fixture reports success. This avoids capturing entry
animations, incomplete placement, or media that has not finished decoding.
Crop only Android system chrome. Preserve Bitchat's app header, translucent
overlap, content, and composer. Derive the crop from the observed status and
navigation insets; do not blindly reuse pixel offsets from a different profile.
Because the app renders edge-to-edge, app controls may extend into the reported
navigation inset. Place the bottom crop after the final control outline and
shadow but before the system gesture affordance; removing the entire inset can
clip the app itself.
Keep every final README screenshot in a matched portrait size.
Use image inspection after the crop. File dimensions and a successful ADB
command do not prove that the desired composition is visible.
## Update repository assets
Discover the current README references before writing. Prefer stable paths under
`docs/screenshots/` and replace only the assets the user requested.
When adding a showcase section:
- keep the layout readable on GitHub;
- give every image meaningful alt text;
- use relative repository paths;
- avoid machine-generated cache files or capture sources;
- keep paired screenshots at identical dimensions.
Run the bundled validator for every final asset:
```sh
python3 \
.agents/skills/android-readme-screenshot-studio/scripts/validate_readme_screenshots.py \
--repo-root . \
--readme README.md \
--require-same-size \
--asset docs/screenshots/readme-mesh-chat.png \
--asset docs/screenshots/readme-geohash-globe.png
```
When replacing only one image in an existing pair, pass both the changed and
unchanged assets with `--require-same-size`, and verify the unchanged asset's
checksum. Pass only one asset and omit `--require-same-size` only when the
README has no paired screenshot to preserve.
## Remove the fixture and verify cleanly
Before committing:
1. Remove temporary imports, commands, helpers, resources, and fixture media
with a focused patch.
2. Verify `git diff -- app/src/debug` is empty.
3. Verify `git status --short` lists only the intended README and screenshot
files.
4. Run `git diff --check`.
5. Run `./gradlew assembleDebug` after fixture removal.
6. Re-run the screenshot validator.
7. Confirm the user's original checkout is still untouched.
The final commit must not contain synthetic peer data, generated photo sources,
audio clips, ADB outputs, emulator configuration, or local capture reports
unless the user separately requested those artifacts in the repository.
## Record honest evidence
Use the capture manifest and report format from
`../android-ui-visual-review/` when before/after evidence is useful. For a
README-only change with no production UI delta, identical before/after images
are acceptable when explicitly labeled “no production UI delta.”
State limitations plainly:
- a populated mesh timeline proves rendering, not physical message delivery;
- a voice row proves waveform rendering, not audio playback;
- an attachment proves image rendering, not media transfer;
- a globe proves picker state, not live location or relay behavior.
## Commit, publish, and optionally merge
GitHub writes require user authorization. When authorized:
1. Stage only intended files.
2. Commit without overriding author or committer identity.
3. Push the `codex/` branch.
4. Use `gh pr create` or update the existing PR.
5. Describe the exact capture sequence, synthetic fixture disclosure,
repository-safe validation commands, and limitations. Keep emulator,
runtime, hardware, and local-environment facts out of GitHub text whenever
repository privacy rules classify them as machine identifiers.
6. Verify the PR head and checks with `gh pr view` and `gh pr checks`.
7. Merge only when the user explicitly requested it and required checks allow
it. Prefer the repository's normal merge strategy and use `gh`.
8. Verify the merged state and resulting `main` commit.
When GitHub publication is not authorized, leave the finished commit or local
change in the isolated worktree and hand back its branch and artifact paths.
Do not silently push it.
Do not place local paths, device selectors, generated-image paths, or personal
machine details in commits, PR text, comments, or merge messages.
## Final handoff
Lead with the outcome and include:
- PR and merge URL or status;
- final screenshot paths and dimensions;
- one-line composition summary per surface;
- build and validator results;
- synthetic media disclosure;
- current CI state or merged commit;
- confirmation that the original checkout was not modified.

View File

@ -0,0 +1,59 @@
{
"skill_name": "android-readme-screenshot-studio",
"evals": [
{
"id": 1,
"prompt": "Refresh the Bitchat README with two polished screenshots from the latest main UI: a populated mesh chat and the geohash globe centered on the Middle East without zooming. Use a large high-resolution Android emulator, open a PR, and merge it after checks pass.",
"expected_output": "The agent works in a fresh latest-main worktree, tries the documented current-pair fast path before rediscovering the fixture, captures the real app on a verified large portrait emulator profile, uses deterministic synthetic state, centers the globe without changing whole-Earth scale, removes capture hooks, validates the assets, opens a PR, waits for checks, and merges only because the prompt explicitly authorizes it.",
"files": [],
"expectations": [
"Uses a fresh worktree rooted at current origin/main and leaves the original checkout untouched.",
"Captures from the real Android UI on a verified high-resolution portrait emulator profile.",
"Uses the verified nine-row chat fixture and synthetic thky globe seed as fast starting points when the current UI still matches, while visually validating the result.",
"Waits for fixture completion and quiet UI time, and invalidates the media path cache when replacing image bytes at the same destination.",
"Keeps the requested Middle East focus while preserving whole-Earth zoom.",
"Removes temporary fixtures and passes a clean debug build before committing.",
"Uses gh for the authorized PR and merge without publishing machine identifiers."
]
},
{
"id": 2,
"prompt": "Replace only the README chat image. It should read top to bottom as: a photo, four short messages back and forth, three voice messages back and forth, then one thumbs-up. Keep the current globe exactly as-is and make the speech waveforms look natural.",
"expected_output": "The agent treats the requested order and counts as exact, asks or resolves the photo subject instead of inventing it, uses the real waveform extractor on three distinct speech clips, preserves the globe asset, visually inspects the final crop, and leaves only the chat PNG changed.",
"files": [],
"expectations": [
"Implements exactly one photo, four alternating texts, three alternating voice notes, and one final thumbs-up.",
"Does not infer a photo subject from nicknames or unrelated fixture copy.",
"Produces waveforms from real rights-safe speech audio through the app extractor rather than drawing bars.",
"Preserves the globe asset and verifies that only the chat screenshot remains in the diff.",
"Removes the debug fixture before the final clean build."
]
},
{
"id": 3,
"prompt": "Show me before and after screenshots for the Compose header changes in PR #412 at 320 dp and 411 dp, and post the comparison as a PR comment.",
"expected_output": "The agent recognizes that this is visual regression evidence rather than README marketing capture and routes the task to android-ui-visual-review instead of applying the README screenshot workflow.",
"files": [],
"expectations": [
"Routes the request to android-ui-visual-review.",
"Does not replace README assets.",
"Uses the PR merge-base and paired before/after capture workflow.",
"Publishes only the requested PR comment rather than opening an unrelated screenshot PR."
]
},
{
"id": 4,
"prompt": "The bottom of one or both README showcase screenshots is cropped. Correct the images without changing their content, update the screenshot skill so this does not recur, and open a new PR.",
"expected_output": "The agent inspects both merged assets at full size, identifies every affected screen, recaptures from the real app with the existing deterministic fixtures, and treats the navigation inset as an inspection region rather than an automatic crop. The final crop preserves complete app controls while excluding the system gesture affordance, keeps the pair matched, adds a reusable edge-to-edge guardrail to the skill, validates and visually inspects both images, and opens a sanitized PR.",
"files": [],
"expectations": [
"Inspects both screenshots and corrects every affected asset rather than assuming only one is cropped.",
"Recaptures the real app with the established deterministic synthetic content instead of padding or compositing the merged PNGs.",
"Uses accessibility bounds and full-size pixel inspection to place the bottom crop after complete app controls and before system gesture chrome.",
"Verifies the entire chat composer border and bottom padding plus every globe action-button corner and shadow are visible.",
"Keeps paired screenshots at identical dimensions and passes the screenshot validator and clean debug build.",
"Updates the skill with a general edge-to-edge crop rule and publishes no device or machine identifiers."
]
}
]
}

View File

@ -0,0 +1,271 @@
# README showcase recipes
Use these recipes as composition guidance, then adapt them to the user's exact
request and the current UI. The requested chronology and framing always win over
the examples.
## Verified current-pair fast path
Use this baseline first when refreshing the existing README mesh-chat and
geohash-globe pair without a material production UI change. It records a
known-good capture, not a permanent UI contract: verify entry points, visible
state, insets, and output dimensions on every run.
### One-pass app preparation
Build and install the ABI-matching debug APK. A generic `app-debug.apk` may not
exist when the project emits ABI splits, so resolve the installed emulator ABI
and select the matching output before searching for alternate build tasks.
Use normal onboarding or a temporary debug preparation command to:
- mark onboarding complete;
- set the synthetic nickname `trailhead`;
- select the production `ChatUiMode.Bubbles` presentation;
- grant only the runtime permissions needed to reach the surface; and
- keep BLE and Wi-Fi Aware debug transport disabled during deterministic
rendering.
Launch the target Activity before injecting process-local state. A preparation
command may use the existing `PermissionManager` and `AppStateStore` APIs, but
must remain capture-only and be removed before the clean build.
### Exact chat fixture used for the current pair
Clear the in-memory showcase state, add four synthetic peers, and use the real
local mesh peer ID only to mark self-authored rows. Use `solace` for the remote
sender and insert these nine rows at one-minute intervals in one operation:
| Order | Sender | Content |
|---|---|---|
| 1 | `trailhead` | mountain image |
| 2 | `solace` | `That view is unreal.` |
| 3 | `trailhead` | `Worth the climb.` |
| 4 | `solace` | `How's the signal up there?` |
| 5 | `trailhead` | `Still holding strong.` |
| 6 | `trailhead` | voice note |
| 7 | `solace` | voice note |
| 8 | `trailhead` | voice note |
| 9 | `solace` | `👍` |
A fixed epoch such as `1767258000000` keeps ordering stable. The displayed
clock text is locale- and time-zone-dependent, so validate consistency rather
than promising a specific rendered hour.
Reuse the existing rights-safe mountain subject when the brief has not changed.
A near-square source crop around 840×800 produced enough image height while
leaving the reaction above the composer. If the crop changes, relaunch the app
or change the cache destination path; the image row can retain the previous
bitmap when the path is reused.
Generate three local, rights-safe speech clips with visibly different cadence,
then transcode them to the app's normal M4A/AAC path. This known-good synthetic
set used roughly 170, 220, and 145 words per minute:
1. `The trail is clear. I can hear you.`
2. `Copy that. Sending one back now.`
3. `Perfect. The mesh is still holding strong.`
Run each file through `AudioWaveformExtractor` and cache its 120-bin result via
`VoiceWaveformCache`. Wait for the fixture's structured success result, then
allow at least two additional seconds for Compose placement and media decoding
before capture.
### Exact globe path used for the current pair
Start the production, non-exported `GeohashPickerActivity` through a temporary
in-app debug command. Seed it with the explicitly synthetic geohash `thky`,
wait about three seconds for the camera to settle, then invoke the production
minus control three times with about one second between changes. The verified
result was precision 1 with label `#t`, the whole Earth visible, and the Arabian
Peninsula/Persian Gulf region beneath the center crosshair.
Treat `thky` as a fast starting point, not a substitute for inspection. Reject
the result if geography, camera distance, grid, label, or controls differ from
the brief. Never source the seed from device location, IP-derived location, or
account data.
### Capture and crop baseline
Capture the full screen only after the UI has been still for at least two
seconds. Re-observe the status and navigation insets, then remove only those
bands while preserving all app UI. Reuse prior offsets only when the local
profile and measured insets still match.
Keep both outputs at identical dimensions. Run the screenshot validator and
inspect both images at full size. Keep raw captures, generated audio, fixture
media, profile facts, and device output local; only the final PNG assets belong
in the repository.
## Mesh-chat showcase
### Visual goal
Make the screen read as a real conversation at a glance:
1. one strong media anchor near the top;
2. a short text exchange with alternating senders;
3. a compact voice-note exchange with visibly different speech envelopes;
4. a small final reaction or acknowledgement;
5. the app header and composer framing the timeline.
Avoid stuffing every supported feature into one frame. The screenshot should
show capability through hierarchy, not through maximum item count.
### Known-good fixture shape
For a request like “photo, messages, three voices, thumbs-up,” use exactly:
| Order | Type | Sender |
|---|---|---|
| 1 | Image | self or remote, according to the story |
| 2 | Short text | other sender |
| 3 | Short text | alternating sender |
| 4 | Short text | alternating sender |
| 5 | Short text | alternating sender |
| 6 | Voice note | sender A |
| 7 | Voice note | sender B |
| 8 | Voice note | sender A |
| 9 | `👍` | sender B |
Keep copy conversational and concise. Use synthetic names and avoid real
locations, contacts, identities, or sensitive content.
For public mesh rendering, pass the current mesh peer ID as `senderPeerID` on
self-authored messages. This exercises the same ownership and color path as
production. Alternate sender IDs so every back-and-forth row renders its sender
header instead of being grouped away.
### Photo framing
A portrait or tall crop can let the latest rows stay visible while the older
photo slides partially behind Bitchat's translucent header. This is visually
useful only when the photo subject remains legible.
- Preserve the app's own rounded image treatment.
- Do not bake UI chrome into the photo.
- Avoid important content under the header overlap.
- Do not select a photo subject from nicknames alone.
- If generating a synthetic photo, obtain or infer subject approval first,
disclose generation, and keep the source outside the final commit unless
requested.
### Natural voice rows
Prepare three short speech clips with different durations, pauses, and cadence.
Locally available offline TTS plus an audio transcoder is sufficient. Use the
same audio format the app normally records or plays, such as M4A.
After copying the files into an app-readable location, create
`BitchatMessageType.Audio` messages that point to those actual files. Let
`AudioWaveformExtractor` and `VoiceWaveformCache` produce the bars.
Reject the capture when:
- all three envelopes look identical;
- the bars are uniform or sinusoidal rather than speech-like;
- duration labels are missing or implausible;
- a waveform is clipped by the screen edge;
- a temporary progress or cancel state is visible.
### Layout tuning
Inject the complete fixture in one operation so the list adopts it as history
instead of animating rows during capture. Let the reverse-layout list settle at
the newest message.
If the oldest photo is not partially visible, prefer changing its aspect ratio
or the number of short text rows over manually scrolling to an unstable offset.
If the newest reaction falls behind the composer, shorten earlier content or
reduce media height. Do not crop app content to solve a fixture problem.
## Geohash globe showcase
### Visual goal
Show:
- the entire Earth;
- a readable geohash grid;
- the selected coarse cell;
- the requested geographic focus beneath the selection crosshair;
- the picker hint and precision controls.
Use the current picker Activity and renderer. Do not composite a globe or grid
outside the app.
### Focus without zoom
When a user says “focus on the Middle East, no zoom”:
1. open or seed the picker at a geohash centered on the requested area;
2. allow the globe to center on that location;
3. reduce precision to the same coarsest whole-Earth level used for the
showcase;
4. preserve camera distance while confirming the center moved;
5. capture with the whole globe still visible.
The exact seed may change with picker implementation. Validate against visible
geography and the selected geohash label rather than assuming the seed worked.
Reject the capture when:
- the requested region is off-center;
- Earth is clipped;
- reducing precision also changed the camera distance against the brief;
- the selected cell or crosshair is illegible;
- controls overlap the globe;
- stale system bars remain in the final README asset.
## Crop and output
Capture the full physical screen first. Determine the status-bar and
navigation-bar insets from the current profile, then crop those insets only.
Treat the navigation inset as an inspection region, not an automatic crop
amount. Bitchat uses edge-to-edge layout, so the composer's border, globe-button
corners, or their shadows may occupy part of that region. Use accessibility
bounds plus full-size pixel inspection to place the crop below every app
control and above the system gesture affordance.
Treat previously observed crop offsets as local evidence, not as a universal
rule or repository documentation. Re-measure when the profile changes.
Every paired README image should:
- be a valid PNG;
- share width and height;
- retain the app header and composer or controls;
- exclude Android status and gesture/navigation chrome;
- remain sharp at GitHub's rendered width.
## Acceptance checklist
### Chat
- [ ] Media subject matches the user's brief.
- [ ] Message chronology exactly matches the requested sequence.
- [ ] Sender ownership and alternation are correct.
- [ ] Voice-note count is exact.
- [ ] Waveforms were extracted from real speech audio and look distinct.
- [ ] Final reaction is visible above the composer.
- [ ] Header peer count and nickname are synthetic and intentional.
- [ ] The complete composer border and bottom padding are visible.
### Globe
- [ ] Requested region is centered.
- [ ] Zoom level matches the brief.
- [ ] Whole Earth and grid are visible.
- [ ] Selected cell and crosshair are legible.
- [ ] Hint and precision controls are unobstructed.
- [ ] Every action-button corner and shadow is fully visible.
### Repository
- [ ] Only requested README and PNG assets remain in the diff.
- [ ] Temporary debug fixture and media are removed.
- [ ] Final clean debug build passes.
- [ ] Screenshot validator passes.
- [ ] PR text contains no machine or personal identifiers.
- [ ] Static-capture limitations are disclosed.

View File

@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Validate high-resolution README screenshot assets without external packages."""
from __future__ import annotations
import argparse
import json
import struct
import sys
from pathlib import Path
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def fail(message: str) -> None:
raise ValueError(message)
def png_dimensions(path: Path) -> tuple[int, int]:
with path.open("rb") as handle:
header = handle.read(24)
if len(header) < 24 or header[:8] != PNG_SIGNATURE or header[12:16] != b"IHDR":
fail(f"{path.name} is not a valid PNG with an IHDR header")
width, height = struct.unpack(">II", header[16:24])
if width <= 0 or height <= 0:
fail(f"{path.name} has invalid dimensions: {width}x{height}")
return width, height
def resolve_inside(root: Path, value: Path, label: str) -> tuple[Path, Path]:
candidate = value if value.is_absolute() else root / value
resolved = candidate.resolve()
try:
relative = resolved.relative_to(root)
except ValueError as exc:
raise ValueError(f"{label} must stay inside the repository") from exc
if not resolved.is_file():
fail(f"{label} does not exist: {relative.as_posix()}")
return resolved, relative
def main() -> int:
parser = argparse.ArgumentParser(
description="Validate README PNG references, dimensions, and repository-safe paths."
)
parser.add_argument("--repo-root", type=Path, default=Path("."))
parser.add_argument("--readme", type=Path, default=Path("README.md"))
parser.add_argument("--asset", action="append", type=Path, required=True)
parser.add_argument("--min-width", type=int, default=1080)
parser.add_argument("--min-height", type=int, default=1920)
parser.add_argument("--require-same-size", action="store_true")
args = parser.parse_args()
root = args.repo_root.resolve()
if not root.is_dir():
fail("repository root does not exist")
if args.min_width <= 0 or args.min_height <= 0:
fail("minimum dimensions must be positive")
readme_path, readme_relative = resolve_inside(root, args.readme, "README")
readme_text = readme_path.read_text(encoding="utf-8")
assets: list[dict[str, object]] = []
seen: set[Path] = set()
dimensions: set[tuple[int, int]] = set()
for index, value in enumerate(args.asset):
asset_path, relative = resolve_inside(root, value, f"asset[{index}]")
if asset_path in seen:
fail(f"duplicate asset: {relative.as_posix()}")
seen.add(asset_path)
if asset_path.suffix.lower() != ".png":
fail(f"README screenshot must be a PNG: {relative.as_posix()}")
if relative.parts[:2] != ("docs", "screenshots"):
fail(
"README screenshots must live under docs/screenshots: "
f"{relative.as_posix()}"
)
reference = relative.as_posix()
if reference not in readme_text and f"./{reference}" not in readme_text:
fail(f"README does not reference asset: {reference}")
width, height = png_dimensions(asset_path)
if width < args.min_width or height < args.min_height:
fail(
f"{reference} is below the high-resolution minimum: "
f"{width}x{height} < {args.min_width}x{args.min_height}"
)
dimensions.add((width, height))
assets.append({"path": reference, "width": width, "height": height})
if args.require_same_size and len(dimensions) != 1:
rendered = ", ".join(f"{width}x{height}" for width, height in sorted(dimensions))
fail(f"README screenshots do not share one size: {rendered}")
print(
json.dumps(
{
"status": "ok",
"readme": readme_relative.as_posix(),
"assets": assets,
"same_size": len(dimensions) == 1,
},
indent=2,
)
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, UnicodeError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(1)

View File

@ -0,0 +1,255 @@
---
name: android-ui-visual-review
description: Analyze an Android pull request, branch, commit, or patch for user-visible changes and produce reproducible before/after screenshots from isolated builds. Use this skill whenever a user asks for PR screenshots, branch UI comparisons, visual regression evidence, Compose before/after captures, populated message-state screenshots, responsive/theme/locale comparisons, or GitHub comments containing Android UI evidence—even if they only say “show me what changed.” Also use it to determine and document that a suspected UI PR has no visual delta. Do not use it for implementing a new UI, ordinary code review without visual evidence, or physical mesh validation.
compatibility: Requires git, the Android SDK and emulator, adb, Java/Gradle, Python 3, and gh for pull-request resolution or publishing.
---
# Android UI Visual Review
Turn a PR or branch into trustworthy visual evidence. The comparison is useful
only when the before and after builds use the correct commits, Android runtime,
viewport, app state, and navigation path.
## Collect the two user choices
Before starting, resolve:
1. **Target** — a PR URL/number or a branch/commit. If it is missing, ask for it.
For a branch, also ask for the intended base when it cannot be inferred
safely; otherwise default to the repository's `main`.
2. **Publishing** — for a PR target, ask whether the final screenshots and
findings should stay local or be posted as a PR comment. Do not perform any
GitHub write unless the user explicitly chooses publishing. A prior explicit
request such as “post these to the PR” already answers this question.
Do not block on publishing preference while doing read-only analysis if the user
has not answered yet. Keep the local workflow useful on its own.
## Read the routed guidance
- Read [references/analysis-playbook.md](references/analysis-playbook.md) for
every run. It explains how to map a diff to screens, states, and a capture
matrix.
- Read [references/fixture-recipes.md](references/fixture-recipes.md) whenever
the affected screen needs messages, peers, channels, nicknames, settings,
permissions, locale, theme, onboarding state, or another non-empty fixture.
- Read [references/github-publishing.md](references/github-publishing.md) only
when the user has opted into a PR comment.
## Create an isolated review session
Never switch the user's active checkout between before and after revisions.
Run from the repository root:
```sh
.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh \
--target "<PR URL, PR number, branch, or commit>"
```
For a branch with a non-default base:
```sh
.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh \
--target "<branch>" \
--base "<base ref>"
```
The script fetches a PR head when needed, computes the **actual merge-base**,
creates a detached temporary worktree at the before SHA, and prints:
- session directory
- worktree path
- artifact directory
- before and after SHAs
- PR number/base metadata when applicable
Keep artifacts outside the worktree so checkouts cannot remove them. The script
may symlink the ignored `local.properties` into the temporary worktree; never
publish it or quote its contents.
If a review session already exists and its SHAs are verified, reuse it. Do not
create a second worktree for the same run.
## Establish the visual contract
Use the actual diff, not the PR title, to determine what should be visible.
1. Record `git diff --stat`, `--name-status`, and the focused diff between the
before and after SHAs.
2. Trace changed UI symbols to their composable/activity, state source, entry
point, and prerequisites.
3. Separate direct visual changes from indirect ones such as dynamic color,
locale recreation, launcher resources, default data, or backend state shown
by an otherwise unchanged screen.
4. Produce a local capture matrix before building. Each row should define:
screen, navigation path, fixture, logical width, theme, locale, permissions,
and what difference is expected.
5. Include a control state where the UI should remain unchanged when that helps
distinguish intentional degradation from a regression.
When the diff contains no UI/resource/state-to-UI change, say so. If the user
asked for a screenshot for every target, capture the nearest affected surface
before and after and label the expected result **no visual delta**. Do not invent
a UI claim for a behavioral fix.
## Use the newest stable Android runtime
Prefer an emulator for repeatable UI evidence. At run time:
1. Inspect installed SDK/system images and current official Android release
information. Choose the newest **stable** API; do not silently use a preview.
2. Create a dedicated AVD and isolated data directory when possible. If current
command-line tools cannot create a newly versioned image (for example an
extension image), a verified `-sysdir` override with an isolated data
directory is acceptable.
3. After boot, record guest properties rather than trusting the AVD name:
```sh
adb -s "$ANDROID_REVIEW_SERIAL" shell getprop ro.build.version.release
adb -s "$ANDROID_REVIEW_SERIAL" shell getprop ro.build.version.sdk
adb -s "$ANDROID_REVIEW_SERIAL" shell getprop ro.build.version.security_patch
adb -s "$ANDROID_REVIEW_SERIAL" shell wm size
adb -s "$ANDROID_REVIEW_SERIAL" shell wm density
```
Use an explicit emulator serial for every ADB command when any physical device
is also connected. Never put serials, device names, local paths, IP addresses,
or other machine identifiers into reports or GitHub comments.
Use a physical device only when the affected UI depends on hardware that the
emulator cannot reproduce. Ask before changing or clearing a physical device.
This skill does not replace Mesh Lab: if the diff changes mesh, transport,
crypto, service, or physical peer behavior, use the `mesh-lab` skill separately
before claiming the behavior works.
## Build and capture the before state
The worktree starts at the before SHA.
1. Build the debug APK with `./gradlew assembleDebug`.
2. Install the ABI-matching APK with `adb install -r`.
3. Complete stable prerequisites such as onboarding and permissions.
4. Apply the fixture from the capture matrix.
5. Navigate using semantic/UI-automator evidence where possible. Use coordinate
taps only after inspecting the current screen, and keep coordinates local.
6. Capture every matrix row with a descriptive name:
```text
before-<surface>-<state>-<width>-<theme>.png
```
Use `adb exec-out screencap -p` so the PNG is written directly to the artifact
directory. Inspect every screenshot immediately; a successful command is not
proof that the intended screen was visible.
## Build and capture the after state
Before switching commits:
- Preserve the artifact directory outside the worktree.
- Preserve only intentional app state.
- Record any temporary debug fixture patch.
Checkout the recorded after SHA in the detached worktree, reapply the same
debug-only fixture if needed, build, and install with `-r` when state
preservation is part of the comparison.
Replay the same navigation and capture matrix. Name files with the matching
`after-` prefix. If reinstalling cannot preserve the state, replay the fixture
from its recorded inputs rather than comparing different states.
For responsive captures, record logical width in dp. On a fixed-pixel emulator,
changing density is acceptable when the calculation is documented:
```text
density = physical_width_px × 160 / desired_width_dp
```
Restore the original density/theme/locale after the matrix is complete.
## Use deterministic artificial data
Prefer existing debug hooks. When they cannot express the visual state, add the
smallest temporary command to
`app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt`.
The fixture must:
- stay under `src/debug`
- use synthetic names/content and deterministic IDs
- use the current mesh peer ID for self-authored messages so alignment logic is
exercised correctly
- use a fixed fixture epoch passed to both builds
- return structured success data through the existing test-hook result file
- be logically identical in before and after builds
After the final capture, remove the temporary fixture with a focused patch and
verify that the review worktree has no tracked modifications. Never commit or
publish the fixture unless the user separately asks to productize it.
## Validate and report
Create `capture-manifest.json` in the artifact directory using
[assets/capture-manifest.example.json](assets/capture-manifest.example.json) as
the shape. Use paths relative to the manifest and omit device selectors and
local absolute paths.
Validate it:
```sh
python3 \
.agents/skills/android-ui-visual-review/scripts/validate_capture_manifest.py \
"<artifact-directory>/capture-manifest.json"
```
Write a Markdown report next to the manifest with:
- target and exact before/after SHAs
- verified Android release/API/security patch and viewport
- concise code analysis
- visual findings, including intentional non-changes
- side-by-side before/after tables
- fixture disclosure
- limitations and any hardware behavior not exercised
End the local run only after:
- every manifest image exists and is a valid PNG
- each before/after pair has matching pixel dimensions
- every screenshot has been visually inspected
- the worktree has no tracked fixture changes
- the user's original checkout remains untouched
Leave the review session and artifacts available for later inspection unless
the user asks for cleanup.
## Optionally publish to the PR
Only after explicit user approval, follow
[references/github-publishing.md](references/github-publishing.md).
The PR comment should contain:
- Android capture environment
- detected visual changes
- clear before/after labels
- all requested screenshots
- limitations such as “no visual delta” or “hardware race not reproduced”
Use `gh` for all GitHub reads and writes. Verify the posted comment by reading it
back and counting the expected image embeds. Do not expose local paths or
machine identifiers, do not override Git author/committer identity, and do not
push screenshot files to a source branch unless the user separately authorizes
that repository change.
## Final handoff
Give the user:
- report and artifact links
- screenshot count
- one-line result per surface
- PR comment URL when published
- explicit statement that the original checkout was not modified
- any remaining coverage limitation

View File

@ -0,0 +1,32 @@
{
"target": "PR #000 or branch-name",
"before_sha": "40-character merge-base SHA",
"after_sha": "40-character target SHA",
"environment": {
"android_release": "stable release number",
"android_sdk": "API number",
"security_patch": "YYYY-MM-DD",
"resolution_px": "1080x1920",
"default_density_dpi": 420
},
"captures": [
{
"id": "surface-state-411dp-light",
"surface": "Human-readable surface",
"state": "Synthetic fixture and navigation state",
"expected_change": "Specific visual hypothesis",
"before": "before-surface-state-411dp-light.png",
"after": "after-surface-state-411dp-light.png"
}
],
"extras": [
{
"id": "after-expanded-menu",
"role": "after-detail",
"file": "after-expanded-menu.png"
}
],
"limitations": [
"Static screenshots do not prove hardware behavior."
]
}

View File

@ -0,0 +1,20 @@
<!-- android-ui-visual-review:TARGET:AFTER_SHA -->
## Android UI verification
Captured from clean before/after debug builds on **Android ANDROID_RELEASE
(API ANDROID_SDK)**. The before build is this target's actual merge-base.
### Visual changes detected
- FINDING_ONE
- FINDING_TWO
- LIMITATION_OR_INTENTIONAL_NON_CHANGE
### SURFACE_OR_STATE
| Before | After |
|---|---|
| ![Before: ACCESSIBLE_DESCRIPTION](BEFORE_IMAGE_URL) | ![After: ACCESSIBLE_DESCRIPTION](AFTER_IMAGE_URL) |
Fixture disclosure: FIXTURE_DESCRIPTION_OR_NONE.

View File

@ -0,0 +1,41 @@
{
"skill_name": "android-ui-visual-review",
"evals": [
{
"id": 1,
"prompt": "Review PR #813 and make before/after screenshots of its Android UI changes. Keep everything local; do not comment on GitHub.",
"expected_output": "The agent creates an isolated worktree, compares the PR head with its actual merge-base, discovers the header width thresholds, captures a wide control plus the changed compact widths on the newest stable Android emulator, and produces a validated local report without any GitHub write.",
"files": [],
"expectations": [
"Uses the actual PR merge-base rather than current main or head~1.",
"Creates a capture matrix that includes a wide control and every changed width threshold.",
"Populates joined-channel state so the conditional count is visible before testing degradation.",
"Keeps artifacts local and performs no GitHub write."
]
},
{
"id": 2,
"prompt": "Compare branch feature/message-redesign against main. The empty chat will not show the change, so give me populated light and dark screenshots and also show any fresh-install nickname change.",
"expected_output": "The agent traces message rendering and nickname generation, adds a temporary deterministic debug-only fixture with received/self short/wrapped messages, applies equivalent state to both builds, captures light/dark and fresh nickname states, removes the fixture, and validates paired PNGs.",
"files": [],
"expectations": [
"Uses synthetic deterministic messages with the real local peer ID for self classification.",
"Uses one fixed fixture epoch and identical logical data in both builds.",
"Captures both light and dark populated message states plus the fresh nickname state.",
"Removes temporary debug fixture changes before handoff."
]
},
{
"id": 3,
"prompt": "This hotspot PR looks UI-related, but inspect it rather than assuming. Capture before/after and, after you show the evidence, post the screenshots and findings to the PR.",
"expected_output": "The agent determines whether the diff actually changes UI, captures the nearest affected hotspot surface even if equality is expected, labels behavioral limitations, asks or recognizes explicit publishing authorization, uploads safe screenshots without a source branch, posts through gh, and verifies the comment.",
"files": [],
"expectations": [
"Does not invent a visual delta when the diff is backend-only.",
"Labels the screenshots as an expected no-visual-change comparison.",
"Does not claim that a static screenshot proves the hotspot ownership race.",
"Posts only after explicit authorization and verifies image count and comment URL."
]
}
]
}

View File

@ -0,0 +1,152 @@
# Diff-to-screen analysis playbook
Use this reference to turn a code diff into a minimal but complete screenshot
matrix.
## Resolve the correct comparison
For a PR, “before” is the merge-base of the fetched PR head and its configured
base branch. It is not necessarily the PR's current `baseRefOid`, the local
`main`, or the commit immediately preceding the head.
For a branch or commit, confirm the intended base. Compute the merge-base after
fetching both sides.
Record both SHAs before any checkout. Build each SHA rather than attempting to
reverse selected files on a single build.
## Triage the diff in layers
Start broad:
```sh
git diff --stat "$BEFORE_SHA" "$AFTER_SHA"
git diff --name-status "$BEFORE_SHA" "$AFTER_SHA"
git diff "$BEFORE_SHA" "$AFTER_SHA" -- app/src/main app/src/debug
```
Then classify changed files.
| Diff area | Likely visual impact |
|---|---|
| `ui/*.kt`, composables, modifiers, layouts | Direct screen/layout change |
| `ui/theme/*`, colors, shapes, typography | Cross-screen theme change |
| `res/values*`, drawables, mipmaps | Text, locale, icon, launcher, or palette |
| Manifest locale/theme/activity metadata | System or activity presentation |
| `DataManager`, preferences, defaults | Fresh-install/default-state change |
| `AppStateStore`, ViewModel/state flows | UI changes only after specific state |
| Service/transport/backend only | Usually no static UI delta; trace exposed state |
| `src/debug` or Android tests | Fixture/test mechanism, not production UI |
Do not stop at filenames. Search every changed public symbol and resource:
```sh
rg -n "<ChangedSymbol|resource_name>" app/src
```
Trace in both directions:
- Who calls or renders this code?
- Which state branch selects it?
- What user action reaches it?
- What permissions, onboarding, peers, channels, messages, theme, locale, or
width are required?
- Does the change affect an empty screen, only populated state, or both?
## Repository UI surface map
These are orientation points, not a substitute for inspecting the current
revision:
| Surface | Starting points |
|---|---|
| App launch/navigation/permissions | `MainActivity.kt`, `ui/ChatScreen.kt` |
| Top bar, nickname, peer/channel/location controls | `ui/ChatHeader.kt` |
| Message rows, bubbles, timestamps, media | `ui/MessageComponents.kt` |
| App state consumed by Compose | `services/AppStateStore.kt`, ViewModels |
| Default nickname/preferences | `ui/DataManager.kt` |
| About and Settings | `ui/AboutSheet.kt` |
| Location/geohash/channel controls | `ui/LocationChannelsSheet.kt` |
| Hotspot UI | `hotspot/HotspotActivity.kt` |
| Dynamic/fallback colors and shapes | `ui/theme/Theme.kt`, `ThemePreference.kt` |
| Debug ADB hooks | `src/debug/.../testhook/TestHookReceiver.kt`, `TestHookDriver.kt` |
Files and packages can move. Use `rg --files` and symbol search to re-establish
the current map at the target commits.
## Identify indirect UI changes
Some visual changes appear far away from the edited function:
- A nickname generator change is visible only after deleting or bypassing a
saved nickname.
- A Material You change appears only on Android 12+ and depends on the emulator
wallpaper/system palette.
- Locale selection may recreate the Activity and return to a different tab.
- A launcher background is not visible inside the running Activity.
- Message delivery status may appear only for self-authored private messages.
- A width policy may be invisible at the default device width.
- A backend ownership fix may produce no screenshot difference at all.
Write these as explicit hypotheses before capture. Each hypothesis needs either
a matrix row or a documented reason it cannot be shown statically.
## Build the capture matrix
Keep the matrix small enough to review but large enough to hit every changed
branch.
| Field | What to record |
|---|---|
| Surface | Human-readable screen/component |
| Entry path | Actions from launch to the target |
| Fixture | Messages, peer, channel, setting, or empty state |
| Platform | Android API feature needed, such as dynamic color |
| Width | Logical dp breakpoint |
| Theme | System/light/dark |
| Locale | System/default or selected locale |
| Expected before | Specific visual contract |
| Expected after | Specific visual contract |
| Control | State expected not to change, when useful |
Examples:
- Header crowding: 411 dp control, 380 dp middle breakpoint, 320 dp compact
breakpoint, with a joined channel so the count is actually present.
- Message bubbles: identical short/long received and self messages in light and
dark modes.
- Nickname prefix: fresh generated nickname in each build; explain that random
digits differ.
- Language picker: Settings before, Settings after, expanded menu, and one live
selection.
- Backend-only hotspot fix: the nearest hotspot surface before/after, explicitly
expecting equality.
## Distinguish visual proof from behavioral proof
A static screenshot can prove rendering, layout, labels, selected state, and
visible recreation. It cannot prove:
- foreign Wi-Fi group ownership
- BLE/Wi-Fi discovery or delivery
- Noise/identity correctness
- race avoidance
- background lifecycle behavior
- accessibility announcement content without an accessibility inspection
Name those limitations. Use relevant unit/instrumented tests or the repository's
Mesh Lab workflow separately.
## Compare carefully
Treat these as expected noise unless the PR changes them:
- status-bar clock
- battery/network indicator
- random nickname suffix
- dynamic palette when system wallpaper differs
- asynchronous peer counts
- animation frame
Stabilize or disclose the noise. Never describe it as a PR effect.

View File

@ -0,0 +1,209 @@
# Deterministic UI fixture recipes
Read current code before applying any recipe. These patterns intentionally use
the debug-only test-hook path and should be adapted to the APIs present at both
comparison SHAs.
## Fixture principles
1. Exercise the UI's real state classification. A self message must carry the
peer identity that production code uses to decide `isSelf`; changing only the
displayed sender nickname can produce a false layout.
2. Use the same logical records in both builds: stable IDs, sender IDs, content,
ordering, and one fixed epoch.
3. Keep content synthetic and review-safe. Do not use real messages, contacts,
peer IDs, channel memberships, device information, or locations.
4. Return structured success data and verify it before capturing.
5. Keep additions under `app/src/debug`; remove them after capture.
## Prefer the existing test hook
Inspect:
- `app/src/debug/java/com/bitchat/android/testhook/TestHookReceiver.kt`
- `app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt`
- `app/src/debug/AndroidManifest.xml`
The receiver accepts:
```sh
adb -s "$ANDROID_REVIEW_SERIAL" shell am broadcast \
-n com.bitchat.droid/com.bitchat.android.testhook.TestHookReceiver \
-a com.bitchat.droid.TEST_HOOK \
--es cmd "<command>" \
--es id "<unique-result-id>"
```
Read the result rather than trusting broadcast delivery:
```sh
adb -s "$ANDROID_REVIEW_SERIAL" shell run-as com.bitchat.droid \
cat "cache/testhook/results/<unique-result-id>.json"
```
Existing commands such as `set_nickname`, `broadcast_msg`, and state inspection
may already be sufficient.
## Public message fixture
When existing commands cannot create both received and self messages without a
second device, add a temporary `ui_fixture` command to `TestHookDriver`.
Adapt imports and constructor fields to the checked-out revision. The core shape
is:
```kotlin
private fun uiFixture(context: Context, intent: Intent): JSONObject {
val mesh = mesh(context)
val nickname = AppStateStore.nickname.value
.ifBlank { DataManager(context).loadNickname() }
val epochMs = intent.getLongExtra("fixture_epoch_ms", 1_800_000_000_000L)
listOf(
BitchatMessage(
id = "ui-fixture-received-short",
sender = "mara",
content = "Are you seeing this?",
timestamp = Date(epochMs),
senderPeerID = "ui-fixture-peer",
),
BitchatMessage(
id = "ui-fixture-received-wrap",
sender = "mara",
content = "The mesh stays readable even when a message wraps onto a second line.",
timestamp = Date(epochMs + 60_000),
senderPeerID = "ui-fixture-peer",
),
BitchatMessage(
id = "ui-fixture-self-short",
sender = nickname,
content = "Yep — testing the message layout.",
timestamp = Date(epochMs + 120_000),
senderPeerID = mesh.myPeerID,
),
BitchatMessage(
id = "ui-fixture-self-wrap",
sender = nickname,
content = "Short and long bubbles should align consistently.",
timestamp = Date(epochMs + 180_000),
senderPeerID = mesh.myPeerID,
),
).forEach(AppStateStore::addPublicMessage)
return ok("ui_fixture")
.put("messages", 4)
.put("fixture_epoch_ms", epochMs)
}
```
Why these details matter:
- received/self exercises both alignment branches
- short/wrapped content exercises intrinsic and capped width
- fixed IDs defeat accidental duplication
- a fixed epoch makes before/after timestamps comparable
- `mesh.myPeerID` exercises the real self-classification path
If `AppStateStore` moved packages or the message constructor changed, adapt only
the debug fixture. Do not modify production state logic to accommodate it.
App state is process-local. Launch the Activity before injection, inject after
the process is ready, and capture without force-stopping it.
## Private-message and delivery-status fixture
To inspect private-message bubbles or status placement:
- set the selected private peer in `AppStateStore`
- add messages using `addPrivateMessage`
- use the current local peer ID for self messages
- populate the delivery status explicitly when the changed component reads it
- mark read state consistently
Use a synthetic conversation ID such as `ui-fixture-private-peer`. Avoid writing
to persistent conversation storage unless persistence itself is being reviewed.
If persistence cannot be bypassed safely, use a unique deterministic fixture
conversation and disclose it in the report.
## Fresh default nickname
A saved preference hides generator changes. Add a temporary command that removes
only the nickname preference, invokes the revision's real generator, and updates
the in-memory store:
```kotlin
private fun resetNicknameFixture(context: Context): JSONObject {
context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE)
.edit()
.remove("nickname")
.commit()
val nickname = DataManager(context).loadNickname()
AppStateStore.setNickname(nickname)
return ok("reset_nickname_fixture").put("nickname", nickname)
}
```
First inspect `DataManager` to confirm the preference file/key. Do not use this
recipe blindly after storage migrations.
Random suffixes are expected to differ. Compare the changed prefix/pattern, not
the digits. Do not seed or replace the production generator merely to make the
screenshot deterministic.
## Channel/header state
Responsive header screenshots need every conditional item present.
- Join a synthetic channel such as `#review` through the normal UI or existing
debug state API.
- Confirm the joined count is visible at the control width before changing
density.
- Keep the same nickname, channel, peer count, and location state in both builds.
- Capture a wide control and every threshold changed by the diff.
Do not use a real geohash/location. Synthetic channel state is enough for header
crowding unless the actual geohash label is the feature under review.
## Theme and dynamic color
- Force light and dark through `adb shell cmd uimode night no|yes`.
- Record the system wallpaper/palette only as anonymous environment context.
- Keep the same emulator data directory between builds so Material You input is
identical.
- Capture both themes when theme code, containers, surfaces, or contrast changes.
- Restore the original mode after capture.
## Locale
Use the new in-app picker when that is the feature. Capture:
1. Settings before
2. Settings with the new row
3. expanded picker
4. one live language selection
Locale application may recreate the Activity and reset the selected tab. This is
expected; navigate again rather than assuming the selection failed. Restore
System default after capture.
## Onboarding and permissions
Complete onboarding/permissions once in the before build, then use `adb install
-r` for after when signatures and data schemas are compatible.
If state cannot be preserved, record and replay each action. Do not clear all app
data merely to change one preference. Never run `pm clear` on a physical device
without explicit authorization.
## Remove the fixture
Use a focused patch to remove only additions made for capture. Then check:
```sh
git diff -- app/src/debug
git status --short
```
The finished worktree may contain untracked artifacts only when artifacts were
intentionally stored there. It must not contain tracked fixture changes.

View File

@ -0,0 +1,97 @@
# Publishing screenshots to a pull request
Follow this only after the user explicitly opts into a GitHub comment.
## Prepare a safe comment
The comment should contain:
- a unique hidden marker for idempotent verification
- verified Android release/API
- statement that before is the target's actual merge-base
- concise visual findings
- paired before/after image tables
- fixture disclosure when artificial state was used
- limitations and intentional non-changes
Do not include:
- local paths or usernames
- ADB serials, device names, peer IDs, addresses, or IPs
- real messages, contacts, locations, or account information
- build logs
- claims about physical behavior that screenshots do not prove
Scan the body locally for machine paths and selectors before posting.
Use [../assets/pr-comment-template.md](../assets/pr-comment-template.md) as the
starting structure, replacing every uppercase placeholder and adding one table
per comparison state.
## Upload image bytes without changing a source branch
Native `gh pr comment` accepts Markdown but not binary files. This skill bundles
an uploader that uses `gh api` to place images on a PR-scoped custom Git ref:
```sh
python3 \
.agents/skills/android-ui-visual-review/scripts/upload_pr_images.py \
--repo OWNER/REPO \
--pr NUMBER \
<ordered PNG files>
```
The helper:
- uploads only image bytes and basenames
- writes to `refs/uploads/issues/<NUMBER>`, outside `refs/heads/*`
- does not pass an author or committer override
- prints JSON containing stable GitHub blob URLs
- does not post a comment
This is a GitHub repository write even though it does not create a visible
branch. The user's approval to publish the screenshots authorizes this
PR-scoped storage. If repository policy rejects custom refs, use an authenticated
GitHub web attachment composer or ask the user for an approved image host. Do
not fall back to a source branch without separate authorization.
Use `--dry-run` first when validating new inputs:
```sh
python3 \
.agents/skills/android-ui-visual-review/scripts/upload_pr_images.py \
--dry-run \
--repo OWNER/REPO \
--pr NUMBER \
<ordered PNG files>
```
## Post through gh
Build the final Markdown body with the returned URLs. Keep before and after in
the same table row. Put extra after-state details, such as an expanded menu, in
a separate labeled table.
Post once:
```sh
gh pr comment NUMBER \
--repo OWNER/REPO \
--body-file "<validated-comment.md>"
```
Record the returned comment URL.
## Verify the side effect
Read the comment back using `gh api` or `gh pr view`. Verify:
- the unique marker is present
- image embed count equals the requested screenshot count
- no local paths or identifiers were included
- the returned URL belongs to the intended PR
If the posting command's outcome is ambiguous, query for the marker before
retrying. Do not create duplicate comments.
Report the comment URL to the user. Leave the PR-scoped image ref in place while
the comment depends on it; deleting the ref can eventually break the images.

View File

@ -0,0 +1,201 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Create an isolated Android UI review worktree.
Usage:
create_review_worktree.sh --target <PR-URL|PR-NUMBER|BRANCH|COMMIT> [options]
Options:
--base <REF> Base for a branch/commit target (default: main)
--repo-root <DIR> Repository root (default: current repository)
-h, --help Show this help
The script never removes an existing worktree. It prints and writes session.env
with the before/after SHAs and artifact paths.
EOF
}
target=""
base_ref=""
repo_root="."
while [[ $# -gt 0 ]]; do
case "$1" in
--target)
target="${2:-}"
shift 2
;;
--base)
base_ref="${2:-}"
shift 2
;;
--repo-root)
repo_root="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "error: unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "$target" ]]; then
echo "error: --target is required" >&2
exit 2
fi
if [[ "$target" == -* || "$base_ref" == -* ]]; then
echo "error: target and base refs cannot begin with '-'" >&2
exit 2
fi
repo_root="$(git -C "$repo_root" rev-parse --show-toplevel)"
cd "$repo_root"
target_kind="ref"
pr_number=""
base_name=""
head_ref=""
if [[ "$target" =~ ^[0-9]+$ ]]; then
target_kind="pr"
pr_number="${BASH_REMATCH[0]}"
elif [[ "$target" =~ ^https://github\.com/([^/]+)/([^/]+)/pull/([0-9]+)/?$ ]]; then
target_kind="pr"
pr_number="${BASH_REMATCH[3]}"
url_repo="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
current_repo="$(gh repo view --json nameWithOwner --jq .nameWithOwner)"
if [[ "${url_repo,,}" != "${current_repo,,}" ]]; then
echo "error: PR URL targets $url_repo but this checkout is $current_repo" >&2
exit 2
fi
fi
resolve_target_ref() {
local ref="$1"
local resolved=""
if git show-ref --verify --quiet "refs/heads/${ref}"; then
git rev-parse --verify "refs/heads/${ref}^{commit}"
return
fi
if [[ "$ref" == refs/* || "$ref" == *"~"* || "$ref" == *"^"* ]] &&
resolved="$(git rev-parse --verify "${ref}^{commit}" 2>/dev/null)"; then
printf '%s\n' "$resolved"
return
fi
if git check-ref-format --branch "$ref" >/dev/null 2>&1; then
if git fetch origin "$ref" >/dev/null 2>&1; then
git rev-parse --verify 'FETCH_HEAD^{commit}'
return
fi
fi
if resolved="$(git rev-parse --verify "${ref}^{commit}" 2>/dev/null)"; then
printf '%s\n' "$resolved"
return
fi
echo "error: cannot resolve ref: $ref" >&2
return 1
}
resolve_base_ref() {
local ref="$1"
local resolved=""
if git check-ref-format --branch "$ref" >/dev/null 2>&1; then
if git fetch origin "$ref" >/dev/null 2>&1; then
git rev-parse --verify 'FETCH_HEAD^{commit}'
return
fi
fi
if resolved="$(git rev-parse --verify "${ref}^{commit}" 2>/dev/null)"; then
printf '%s\n' "$resolved"
return
fi
echo "error: cannot resolve base ref: $ref" >&2
return 1
}
if [[ "$target_kind" == "pr" ]]; then
repo_slug="$(gh repo view --json nameWithOwner --jq .nameWithOwner)"
pr_data="$(gh pr view "$pr_number" --repo "$repo_slug" \
--json baseRefName,headRefOid \
--jq '"\(.baseRefName) \(.headRefOid)"')"
read -r base_name reported_head_sha <<<"$pr_data"
head_ref="refs/pr-visual-review/pull/${pr_number}/head"
git fetch origin "+pull/${pr_number}/head:${head_ref}"
git fetch origin "+refs/heads/${base_name}:refs/remotes/origin/${base_name}"
after_sha="$(git rev-parse --verify "${head_ref}^{commit}")"
if [[ "$after_sha" != "$reported_head_sha" ]]; then
latest_reported_head="$(gh pr view "$pr_number" --repo "$repo_slug" \
--json headRefOid --jq .headRefOid)"
if [[ "$after_sha" != "$latest_reported_head" ]]; then
echo "error: PR head changed while resolving; rerun for a consistent target" >&2
exit 1
fi
fi
base_sha="$(git rev-parse --verify "refs/remotes/origin/${base_name}^{commit}")"
else
after_sha="$(resolve_target_ref "$target")"
base_name="${base_ref:-main}"
base_sha="$(resolve_base_ref "$base_name")"
fi
before_sha="$(git merge-base "$base_sha" "$after_sha")"
if [[ -z "$before_sha" ]]; then
echo "error: no merge-base found between $base_name and $target" >&2
exit 1
fi
session_dir="$(mktemp -d /tmp/bitchat-ui-review.XXXXXX)"
worktree_path="${session_dir}/worktree"
artifact_dir="${session_dir}/artifacts"
mkdir -p "$artifact_dir"
git worktree add --detach "$worktree_path" "$before_sha"
if [[ -f "${repo_root}/local.properties" && ! -e "${worktree_path}/local.properties" ]]; then
ln -s "${repo_root}/local.properties" "${worktree_path}/local.properties"
fi
session_env="${session_dir}/session.env"
{
printf 'SESSION_DIR=%q\n' "$session_dir"
printf 'WORKTREE_PATH=%q\n' "$worktree_path"
printf 'ARTIFACT_DIR=%q\n' "$artifact_dir"
printf 'SOURCE_REPO_ROOT=%q\n' "$repo_root"
printf 'TARGET_KIND=%q\n' "$target_kind"
printf 'TARGET_INPUT=%q\n' "$target"
printf 'BASE_NAME=%q\n' "$base_name"
printf 'BEFORE_SHA=%q\n' "$before_sha"
printf 'AFTER_SHA=%q\n' "$after_sha"
printf 'PR_NUMBER=%q\n' "$pr_number"
} > "$session_env"
printf 'SESSION_DIR=%s\n' "$session_dir"
printf 'WORKTREE_PATH=%s\n' "$worktree_path"
printf 'ARTIFACT_DIR=%s\n' "$artifact_dir"
printf 'BEFORE_SHA=%s\n' "$before_sha"
printf 'AFTER_SHA=%s\n' "$after_sha"
printf 'TARGET_KIND=%s\n' "$target_kind"
if [[ -n "$pr_number" ]]; then
printf 'PR_NUMBER=%s\n' "$pr_number"
fi
printf 'SESSION_ENV=%s\n' "$session_env"

View File

@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Upload PR screenshots through gh api without modifying a source branch."""
from __future__ import annotations
import argparse
import base64
import json
import re
import subprocess
import sys
from pathlib import Path
from urllib.parse import quote
REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
def gh_api(
endpoint: str,
*,
method: str = "GET",
payload: dict[str, object] | None = None,
allow_not_found: bool = False,
) -> dict[str, object] | None:
command = ["gh", "api"]
if method != "GET":
command.extend(["-X", method])
command.append(endpoint)
input_text = None
if payload is not None:
command.extend(["--input", "-"])
input_text = json.dumps(payload)
result = subprocess.run(
command,
input=input_text,
text=True,
capture_output=True,
check=False,
)
if result.returncode != 0:
if allow_not_found and "HTTP 404" in result.stderr:
return None
raise RuntimeError(result.stderr.strip() or f"gh api failed for {endpoint}")
if not result.stdout.strip():
return {}
parsed = json.loads(result.stdout)
if not isinstance(parsed, dict):
raise RuntimeError(f"unexpected gh api response for {endpoint}")
return parsed
def validate_files(paths: list[Path]) -> list[tuple[Path, str, int]]:
if not paths:
raise ValueError("at least one image file is required")
files: list[tuple[Path, str, int]] = []
seen: set[str] = set()
for path in paths:
resolved = path.resolve()
if not resolved.is_file():
raise ValueError(f"file not found: {path}")
name = resolved.name
if name in seen:
raise ValueError(f"duplicate basename would collide in upload: {name}")
seen.add(name)
if resolved.suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
raise ValueError(f"unsupported image type: {name}")
files.append((resolved, name, resolved.stat().st_size))
return files
def image_markdown(files: list[dict[str, str]]) -> str:
sections: list[str] = []
for index in range(0, len(files), 2):
pair = files[index : index + 2]
labels = " | ".join(item["name"] for item in pair)
separators = " | ".join("---" for _ in pair)
images = " | ".join(
f'![{item["name"]}]({item["url"]})' for item in pair
)
sections.append(f"| {labels} |\n| {separators} |\n| {images} |")
return "\n\n".join(sections)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repo", required=True, help="OWNER/REPO")
parser.add_argument("--pr", required=True, type=int)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("files", nargs="+", type=Path)
args = parser.parse_args()
if not REPO_RE.fullmatch(args.repo):
raise ValueError("--repo must use OWNER/REPO")
if args.pr <= 0:
raise ValueError("--pr must be a positive integer")
files = validate_files(args.files)
if args.dry_run:
print(
json.dumps(
{
"status": "dry-run",
"repo": args.repo,
"pr": args.pr,
"ref": f"refs/uploads/issues/{args.pr}",
"files": [
{"name": name, "size": size} for _, name, size in files
],
},
indent=2,
)
)
return 0
api_prefix = f"repos/{args.repo}"
ref_path = f"uploads/issues/{args.pr}"
ref_response = gh_api(
f"{api_prefix}/git/ref/{ref_path}",
allow_not_found=True,
)
parent_sha = ""
base_tree_sha = ""
if ref_response is not None:
ref_object = ref_response.get("object")
if not isinstance(ref_object, dict) or not isinstance(ref_object.get("sha"), str):
raise RuntimeError("existing upload ref response did not contain a commit SHA")
parent_sha = str(ref_object["sha"])
parent_commit = gh_api(f"{api_prefix}/git/commits/{parent_sha}")
tree = parent_commit.get("tree") if parent_commit else None
if not isinstance(tree, dict) or not isinstance(tree.get("sha"), str):
raise RuntimeError("existing upload commit did not contain a tree SHA")
base_tree_sha = str(tree["sha"])
entries: list[dict[str, str]] = []
for path, name, _ in files:
content = base64.b64encode(path.read_bytes()).decode("ascii")
blob = gh_api(
f"{api_prefix}/git/blobs",
method="POST",
payload={"content": content, "encoding": "base64"},
)
blob_sha = blob.get("sha") if blob else None
if not isinstance(blob_sha, str):
raise RuntimeError(f"blob upload did not return a SHA for {name}")
entries.append(
{"path": name, "mode": "100644", "type": "blob", "sha": blob_sha}
)
tree_payload: dict[str, object] = {"tree": entries}
if base_tree_sha:
tree_payload["base_tree"] = base_tree_sha
tree_response = gh_api(
f"{api_prefix}/git/trees",
method="POST",
payload=tree_payload,
)
tree_sha = tree_response.get("sha") if tree_response else None
if not isinstance(tree_sha, str):
raise RuntimeError("tree creation did not return a SHA")
commit_response = gh_api(
f"{api_prefix}/git/commits",
method="POST",
payload={
"message": f"Add visual comparison screenshots for PR #{args.pr}",
"tree": tree_sha,
"parents": [parent_sha] if parent_sha else [],
},
)
commit_sha = commit_response.get("sha") if commit_response else None
if not isinstance(commit_sha, str):
raise RuntimeError("commit creation did not return a SHA")
if parent_sha:
gh_api(
f"{api_prefix}/git/refs/{ref_path}",
method="PATCH",
payload={"sha": commit_sha, "force": False},
)
else:
gh_api(
f"{api_prefix}/git/refs",
method="POST",
payload={"ref": f"refs/{ref_path}", "sha": commit_sha},
)
uploaded: list[dict[str, str]] = []
for _, name, _ in files:
url = f"https://github.com/{args.repo}/blob/{commit_sha}/{quote(name)}?raw=true"
uploaded.append({"name": name, "url": url})
print(
json.dumps(
{
"status": "ok",
"repo": args.repo,
"pr": args.pr,
"ref": f"refs/{ref_path}",
"sha": commit_sha,
"files": uploaded,
"markdown": image_markdown(uploaded),
},
indent=2,
)
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(1)

View File

@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Validate a visual-review capture manifest and its PNG evidence."""
from __future__ import annotations
import argparse
import json
import re
import struct
import sys
from pathlib import Path
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
def fail(message: str) -> None:
raise ValueError(message)
def relative_file(root: Path, value: object, field: str) -> Path:
if not isinstance(value, str) or not value:
fail(f"{field} must be a non-empty relative path")
candidate = Path(value)
if candidate.is_absolute() or ".." in candidate.parts:
fail(f"{field} must stay relative to the artifact directory: {value!r}")
resolved = (root / candidate).resolve()
try:
resolved.relative_to(root.resolve())
except ValueError as exc:
raise ValueError(f"{field} escapes the artifact directory: {value!r}") from exc
if not resolved.is_file():
fail(f"{field} does not exist: {value}")
return resolved
def png_dimensions(path: Path) -> tuple[int, int]:
with path.open("rb") as handle:
header = handle.read(24)
if len(header) < 24 or header[:8] != PNG_SIGNATURE or header[12:16] != b"IHDR":
fail(f"not a valid PNG with an IHDR header: {path.name}")
width, height = struct.unpack(">II", header[16:24])
if width <= 0 or height <= 0:
fail(f"invalid PNG dimensions in {path.name}: {width}x{height}")
return width, height
def require_string(data: dict[str, object], key: str) -> str:
value = data.get(key)
if not isinstance(value, str) or not value.strip():
fail(f"{key} must be a non-empty string")
return value
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("manifest", type=Path)
args = parser.parse_args()
manifest_path = args.manifest.resolve()
if not manifest_path.is_file():
fail(f"manifest not found: {manifest_path}")
root = manifest_path.parent
with manifest_path.open(encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
fail("manifest root must be an object")
require_string(data, "target")
before_sha = require_string(data, "before_sha")
after_sha = require_string(data, "after_sha")
if not SHA_RE.fullmatch(before_sha) or not SHA_RE.fullmatch(after_sha):
fail("before_sha and after_sha must be lowercase 40-character Git SHAs")
if before_sha == after_sha:
fail("before_sha and after_sha must differ")
environment = data.get("environment")
if not isinstance(environment, dict):
fail("environment must be an object")
for key in ("android_release", "android_sdk", "security_patch", "resolution_px"):
require_string(environment, key)
density = environment.get("default_density_dpi")
if not isinstance(density, int) or density <= 0:
fail("environment.default_density_dpi must be a positive integer")
captures = data.get("captures")
if not isinstance(captures, list) or not captures:
fail("captures must contain at least one before/after pair")
used_paths: set[Path] = set()
pair_summaries: list[dict[str, object]] = []
seen_ids: set[str] = set()
for index, item in enumerate(captures):
if not isinstance(item, dict):
fail(f"captures[{index}] must be an object")
capture_id = require_string(item, "id")
if capture_id in seen_ids:
fail(f"duplicate capture id: {capture_id}")
seen_ids.add(capture_id)
require_string(item, "surface")
require_string(item, "state")
require_string(item, "expected_change")
before_path = relative_file(root, item.get("before"), f"captures[{index}].before")
after_path = relative_file(root, item.get("after"), f"captures[{index}].after")
for path in (before_path, after_path):
if path in used_paths:
fail(f"screenshot reused by multiple manifest entries: {path.name}")
used_paths.add(path)
before_size = png_dimensions(before_path)
after_size = png_dimensions(after_path)
if before_size != after_size:
fail(
f"{capture_id} dimensions differ: before={before_size[0]}x{before_size[1]}, "
f"after={after_size[0]}x{after_size[1]}"
)
pair_summaries.append(
{
"id": capture_id,
"width": before_size[0],
"height": before_size[1],
}
)
extras = data.get("extras", [])
if not isinstance(extras, list):
fail("extras must be an array")
for index, item in enumerate(extras):
if not isinstance(item, dict):
fail(f"extras[{index}] must be an object")
extra_id = require_string(item, "id")
if extra_id in seen_ids:
fail(f"duplicate capture/extra id: {extra_id}")
seen_ids.add(extra_id)
require_string(item, "role")
path = relative_file(root, item.get("file"), f"extras[{index}].file")
if path in used_paths:
fail(f"screenshot reused by multiple manifest entries: {path.name}")
used_paths.add(path)
png_dimensions(path)
limitations = data.get("limitations", [])
if not isinstance(limitations, list) or not all(
isinstance(item, str) and item.strip() for item in limitations
):
fail("limitations must be an array of non-empty strings")
result = {
"status": "ok",
"pairs": len(captures),
"extras": len(extras),
"png_files": len(used_paths),
"dimensions": pair_summaries,
}
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, json.JSONDecodeError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(1)

View File

@ -0,0 +1,224 @@
---
name: mesh-lab
description: Run, diagnose, and extend bitchat Android Mesh Lab physical-device tests. Use this skill whenever work capable of changing physical peer behavior touches mesh discovery or routing, BLE or Wi-Fi transport, Noise/crypto/identity, foreground-service or power behavior, public or private messaging, file/media transfer, protocol packets, or fragmentation; whenever a user asks for physical-device validation, ADB test hooks, hardware regression reproduction, or a new Mesh Lab scenario; and before claiming that such changes work on real devices, even if the user does not name Mesh Lab explicitly. Do not use it for docs-only, unit-test-only, or pure UI changes that cannot affect mesh or service behavior.
---
# Mesh Lab
Use the repository's debug-only ADB harness to validate mesh behavior on physical
devices. Treat it as a development integration test, not as the privacy-checked
release gate and not as proof about a release APK.
## Establish current ground truth
Run from the repository root.
Before choosing commands or changing a scenario:
1. Read the Mesh Lab section of `AGENTS.md`.
2. Read the "mesh lab" appendix in `docs/release-gate-runbook.md`.
3. Use `python3 tools/release_gate/mesh_lab.py --help` and the relevant
subcommand help.
4. Inspect the selected scenario function in `tools/release_gate/mesh_lab.py`;
its current CLI and assertions are authoritative if documentation has drifted.
5. Inspect
`app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt` before
using an ad-hoc command, diagnosing hook behavior, or extending coverage.
Do not infer a physical pass from unit tests, compilation, old evidence, or a
successful local send call.
## Decide the physical coverage
Inspect the change or requested behavior first. Select the smallest scenario set
that exercises the affected physical contract, expanding to `all` for broad,
cross-cutting, or release-sensitive changes.
| Affected behavior | Start with |
|---|---|
| Discovery, connection management, routing, foreground-service lifecycle | `broadcast`, `dm`, `session_recovery` |
| Background, doze, or power-duty-cycle behavior | Existing setup keeps devices awake and foregrounded; add a focused workflow or use the full release gate |
| Wi-Fi Aware or transport-selection behavior | Existing setup enables BLE and does not pin Wi-Fi; add a transport-specific control/assertion or use the full release gate |
| Noise, crypto, authenticated peer state, identity persistence | `dm`, `file_private`, `session_recovery`, `identity_reset` |
| Public messaging or message delivery | `broadcast`, then `dm` if shared routing changed |
| File/media encoding, transfer, fragmentation, admission limits | `file`, `file_private`, `file_oversize` |
| Packet parsing, bridge/routing, TTL, or protocol changes | `raw`, plus a receiving scenario such as `broadcast` or `dm` |
| Broad mesh or transport refactor | `all` |
| UI-only work with no service, state, or delivery effect | Usually no Mesh Lab run; explain why |
When the selected scenarios do not exercise the new contract, add a focused
scenario instead of treating unrelated green tests as coverage.
## Protect devices and evidence
Mesh Lab setup is destructive to the app's local data. It force-stops the app,
clears package data, regenerates identity, cycles Bluetooth, grants permissions,
and changes wake/lock-screen timeout settings without restoring them.
- Use only designated disposable lab app data and deterministic test content.
- Use two authorized physical Android BLE devices on API 26 or newer. Emulators
do not exercise the required BLE mesh behavior.
- Before `setup`, `identity_reset`, or `all`, confirm that the selected devices
may have bitchat app data cleared. `identity_reset` clears device B even when
setup was skipped. If the user has not already established authorization, ask.
- Never attempt to defeat a secure lock screen. Ask the operator to unlock it.
- Keep every device unlocked, awake, foregrounded, and preferably charging.
- Treat ADB selectors as ephemeral secrets. Do not put serials, device names,
peer IDs, addresses, fingerprints, local home paths, or raw logcat in commits,
pull requests, issues, or published artifacts.
- Write raw evidence under `/tmp`, keep it local, and never commit it. Failure
evidence can include unsanitized logcat and lab identifiers.
- Use debug APKs only. The exported test-hook receiver intentionally has no
production security boundary and must never be moved into `src/main`.
- Use an authorized, controlled lab area. After setup, inspect peer state
locally and stop if an unexpected peer is present before sending broadcasts,
files, or raw packets.
- On a non-dedicated device, record the prior Bluetooth, stay-awake, screen
timeout, and lock-screen-disabled settings locally. Restore only those
recorded values after the run, or tell the operator exactly what remains
changed.
If the hardware, operator confirmation, or prerequisites are unavailable,
report the physical result as `blocked (not run)` and provide the exact handoff
command. Never soften this to "pass" or "probably works."
## Run a two-phone batch
Preflight the environment without copying device selectors into durable output:
```sh
python3 --version
adb devices
./gradlew assembleDebug
```
Set up the disposable pair:
```sh
python3 tools/release_gate/mesh_lab.py setup \
--serial-a "$MESH_SERIAL_A" \
--serial-b "$MESH_SERIAL_B" \
--apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk
```
Build and pass the current-tree debug APK during normal use. If setup
intentionally omits `--apk`, first verify on both devices that the installed
package is debuggable via `run-as` and that its package dump contains
`TestHookReceiver`; do this before any command that clears data.
Inspect `peers` on both devices after setup. Continue only when every discovered
participant belongs to the controlled lab.
Run either the selected scenario or the full suite. Run this entire block in one
shell invocation so the temporary-directory variable cannot disappear between
agent shell calls. Abort the block if the directory is empty or missing before
passing it to `--out`; otherwise an empty path can put private evidence in the
repository. Replace `dm` with
`all` only when full-suite data clearing has been authorized.
```sh
MESH_EVIDENCE_DIR="$(mktemp -d /tmp/meshlab-evidence.XXXXXX)"
if [ -z "$MESH_EVIDENCE_DIR" ] || [ ! -d "$MESH_EVIDENCE_DIR" ]; then
echo "mktemp failed; aborting so evidence cannot land in the repository" >&2
exit 1
fi
chmod 700 "$MESH_EVIDENCE_DIR"
python3 tools/release_gate/mesh_lab.py scenario dm \
--serial-a "$MESH_SERIAL_A" \
--serial-b "$MESH_SERIAL_B" \
--out "$MESH_EVIDENCE_DIR"
```
Rerun `setup` before a fresh scenario batch when prior churn, stale identities,
or zombie GATT links could contaminate the result.
Keep the private evidence only as long as the active investigation needs it.
Do not delete failure evidence that the user still needs; when it is no longer
needed, remove it or move it to an explicitly approved protected location.
## Interpret results precisely
- `dm`, `broadcast`, `file`, and `file_private` include receiver-side assertions.
- `file` and `file_private` currently validate a 1 KB deterministic fixture and
SHA-256 integrity, not sustained or boundary-sized transfer performance.
- `file_oversize` validates sender rejection and receiver absence for a 512 KB
broadcast, not the exact 256/257-fragment boundary.
- `raw` proves that the local transport bridge accepted the injected packet. It
does not prove that another device received or accepted it.
- `session_recovery` proves recoverability after process death, but may fall
back to an explicit handshake; it does not prove a fully automatic recovery.
- `identity_reset` proves new-identity recovery after `pm clear`; stale state for
the old identity is diagnostic evidence rather than a purge assertion.
- The current CLI drives exactly two phones. It does not validate a three-hop
topology, transport-specific Wi-Fi Aware behavior, permission denial, doze,
endurance, resource bounds, transfer cancellation, release builds, or
cross-client compatibility.
- For ad-hoc commands, `status: ok` can mean the command completed without
satisfying the requested state. Inspect fields such as `reached_min_peers`,
`direct`, `established`, or `cancelled`.
- `all` runs scenarios sequentially on evolving device state. In the current
runner it writes combined evidence only, and a failed sub-scenario can abort
aggregation without clean structured evidence. Run selected scenarios
individually first when durable per-scenario evidence matters, then use
`all` as broader regression coverage.
A scenario exits zero on pass and non-zero on failure. On failure, preserve the
local evidence, inspect its error first, then use state dumps and filtered logcat:
```sh
python3 tools/release_gate/mesh_lab.py cmd \
--serial "$MESH_SERIAL_A" state
adb -s "$MESH_SERIAL_A" logcat -d -t 200 -s \
TestHook MessageHandler FragmentManager BitchatFilePacket
```
Common first checks are screen/foreground state, mutual discovery, direct-peer
state, Noise session state, and stale Bluetooth connections. Rerun `setup` only
after preserving useful diagnostics.
The generic `cmd --extra` wrapper does not encode every Android extra type
correctly: Boolean `enabled` and integer `min_peers`/`ttl` are notable cases.
Use a direct `adb shell am broadcast` with `--ez` or `--ei`, after reading the
driver, when exact types matter. Use the top-level `--timeout-ms 30000` option
for command timeouts; never pass `--extra timeout_ms=...`, because it duplicates
the runner's `timeout_ms` keyword and fails before dispatch.
## Add a physical-device scenario
Prefer extending `tools/release_gate/mesh_lab.py` with existing hook commands.
Add or change an Android hook only when the public mesh API cannot express the
required action or observation.
Design the scenario around an observable remote contract:
1. Generate a unique token or deterministic fixture so stale state cannot pass.
2. Start the receiver wait before sending.
3. Assert remote sender identity, content, session state, digest, or expected
absence—not merely that the sender accepted a call.
4. Use bounded timeouts and return structured JSON evidence.
5. For negative tests, assert both the expected sender error and that the
receiver did not observe the artifact.
6. Keep hook code and manifest registration under `src/debug`.
7. Update the runbook scenario table and troubleshooting guidance.
8. Run the new scenario individually, then run relevant neighboring scenarios
or `all` to detect state contamination.
Do not add test-only branches to production mesh code merely to make a scenario
easy to drive.
## Report the outcome
End with a compact physical-test report:
- Change or contract tested
- Device topology: logical roles only, such as phone A to phone B
- Build and scenario names
- Result: `pass`, `fail`, or `blocked (not run)`
- Local evidence directory, clearly marked private and uncommitted
- On failure: the exact violated invariant and the next diagnostic
- Coverage limits and any scenario fallback that weakens the claim
Keep device selectors and raw evidence out of the report, commit, and pull
request.

View File

@ -0,0 +1,41 @@
{
"skill_name": "mesh-lab",
"evals": [
{
"id": 1,
"prompt": "I changed Noise session recovery and authenticated peer-state handling. Validate it on real Android devices before we merge. I have not told you whether any disposable phones are connected.",
"expected_output": "The agent inspects the current Mesh Lab implementation, selects the Noise and churn scenarios, checks hardware and destructive-setup authorization, and reports blocked rather than inventing a pass when devices are unavailable.",
"files": [],
"expectations": [
"Selects dm, file_private, session_recovery, and identity_reset, with any omission explicitly tied to the inspected change.",
"Recognizes that setup clears app data and requires disposable lab devices.",
"Does not run setup, identity_reset, or all without destructive authorization and reports blocked (not run) when hardware is unavailable.",
"Keeps device selectors and raw evidence out of durable artifacts."
]
},
{
"id": 2,
"prompt": "The broadcast fragment cap changed. Two unlocked disposable phones are attached over ADB. Run the relevant Mesh Lab coverage and give me a PR-safe summary.",
"expected_output": "The agent recognizes that the stock fixtures do not prove the exact fragment boundary, adds focused below/at/above-cap receiver-side coverage when the changed contract requires it, runs relevant existing file scenarios, saves private evidence in a protected temporary directory, and produces a sanitized summary.",
"files": [],
"expectations": [
"Uses the debug APK and the repository Mesh Lab runner.",
"Runs file and file_oversize, and runs file_private only if the inspected cap is shared with private transfers.",
"Adds or requires below/at/above-cap receiver-side assertions instead of treating the stock 1 KB and 512 KB fixtures as exact-boundary proof.",
"Stores unsanitized evidence in a unique mode-0700 temporary directory and does not expose ADB selectors."
]
},
{
"id": 3,
"prompt": "I added a new routed acknowledgement packet. Existing mesh_lab scenarios do not prove that the second phone receives and processes it. Add appropriate physical-device coverage.",
"expected_output": "The agent extends the host scenario and only the minimum necessary debug hook, asserting a unique remote observation with bounded timeouts and structured evidence while keeping test code out of production sources.",
"files": [],
"expectations": [
"Uses a unique receiver-side acknowledgement observation rather than treating raw_send success as end-to-end proof.",
"Starts the receiver wait before send and uses bounded timeouts with structured JSON evidence.",
"Keeps any hook and manifest changes under app/src/debug and updates scenario registration and documentation.",
"Handles unsupported device types or topologies explicitly rather than assuming parity."
]
}
]
}

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
.git
.gradle
.reproducible-build
**/build
local.properties
tools/arti-build/.arti-source

View File

@ -1,29 +1,142 @@
name: Android Build
name: Android CI
on:
workflow_dispatch:
push:
branches:
- '**'
branches: [main, develop]
pull_request:
branches:
- '**'
branches: [main, develop]
permissions:
contents: read
env:
SETUP_JAVA_VERSION: 21.0.11+10.0.LTS
jobs:
build:
runs-on: ubuntu-latest
verify:
name: Test and lint
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Set up JDK 17
uses: actions/setup-java@v3
- name: Set up pinned JDK
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
with:
java-version: '17'
distribution: 'temurin'
distribution: temurin
java-version: ${{ env.SETUP_JAVA_VERSION }}
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
- name: Set up and validate Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Build with Gradle
run: ./gradlew build
- name: Verify native library inputs
run: tools/arti-build/verify-checksums.sh
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Run lint
run: ./gradlew lintDebug
- name: Upload test reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: test-reports
path: |
**/build/test-results/
**/build/reports/tests/
- name: Upload lint results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: lint-results
path: "**/build/reports/lint-results-*.html"
build-debug:
name: Build debug APK
runs-on: ubuntu-24.04
needs: verify
steps:
- name: Checkout code
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Set up pinned JDK
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
with:
distribution: temurin
java-version: ${{ env.SETUP_JAVA_VERSION }}
- name: Set up and validate Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Build debug APK
run: ./gradlew assembleDebug
- name: Upload debug APK
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: Debug-apk
path: app/build/outputs/apk/debug/*.apk
reproducible-build:
name: Reproducible release build ${{ matrix.replica }}
runs-on: ubuntu-24.04
needs: verify
strategy:
fail-fast: false
matrix:
replica: [a, b]
steps:
- name: Checkout code
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Set up pinned JDK
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
with:
distribution: temurin
java-version: ${{ env.SETUP_JAVA_VERSION }}
- name: Set up and validate Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-disabled: true
- name: Build canonical unsigned release
run: tools/reproducible-builds/build-in-container.sh "$RUNNER_TEMP/release-${{ matrix.replica }}"
- name: Upload build replica
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: reproducible-release-${{ matrix.replica }}
path: ${{ runner.temp }}/release-${{ matrix.replica }}/*
if-no-files-found: error
compare-reproducible-builds:
name: Compare release bytes
runs-on: ubuntu-24.04
needs: reproducible-build
steps:
- name: Checkout verification script
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Download replica A
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: reproducible-release-a
path: ${{ runner.temp }}/release-a
- name: Download replica B
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: reproducible-release-b
path: ${{ runner.temp }}/release-b
- name: Compare every canonical byte
run: tools/reproducible-builds/compare-release.sh "$RUNNER_TEMP/release-a" "$RUNNER_TEMP/release-b"

40
.github/workflows/fetch-georelays.yml vendored Normal file
View File

@ -0,0 +1,40 @@
name: Fetch GeoRelays Data
on:
schedule:
- cron: '0 6 * * 0'
workflow_dispatch:
permissions:
contents: write
jobs:
update-relay-data:
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Fetch GeoRelays
run: |
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
mv nostr_relays.csv ./app/src/main/assets/nostr_relays.csv
- name: Check for changes
id: git-check
run: |
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
- name: Commit and push changes
if: steps.git-check.outputs.changes == 'true'
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add app/src/main/assets/nostr_relays.csv
git commit -m "Automated update of relay data - $(date -u)"
git push
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -2,99 +2,118 @@ name: Release
on:
workflow_dispatch:
inputs:
tag:
description: Existing vX.Y.Z tag to build and attest
required: true
type: string
push:
tags:
- 'v*.*.*' # Triggers for tags like v1.0.0
- "v*.*.*"
permissions:
contents: read
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
jobs:
build:
runs-on: ubuntu-latest
name: Build unsigned release ${{ matrix.replica }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
replica: [a, b]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
with:
gradle-version: wrapper
- name: Cache Gradle files
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Grant execute permission for Gradlew
run: chmod +x ./gradlew
- name: Build APK
run: ./gradlew assembleRelease --no-daemon --stacktrace
- name: List APK files
- name: Validate release tag
run: |
echo "APK files built:"
find app/build/outputs/apk/release -name "*.apk" -type f
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "error: release tag must look like vX.Y.Z" >&2
exit 1
fi
- name: Rename APK
run: |
mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat.apk
- name: DEBUG
run: |
set -x
pwd
ls -all
cd app/build/outputs/
ls -all
tree
# Optional: Sign APK (requires secrets)
# - name: Sign APK
# uses: r0adkll/sign-android-release@v1
# with:
# releaseDirectory: app/build/outputs/apk/release
# signingKeyBase64: ${{ secrets.SIGNING_KEY }}
# alias: ${{ secrets.ALIAS }}
# keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
# keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Upload APK as artifact
uses: actions/upload-artifact@v4
- name: Checkout tagged source
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
name: bitchat-release-apk-${{ github.ref_name }}
path: app/build/outputs/apk/release/*.apk
ref: ${{ env.RELEASE_TAG }}
- name: Verify workflow provenance ref
run: |
checked_out_commit="$(git rev-parse HEAD)"
if [ "$checked_out_commit" != "$GITHUB_SHA" ]; then
echo "error: run the workflow from the $RELEASE_TAG tag ref so provenance identifies the built commit" >&2
exit 1
fi
- name: Validate the Gradle wrapper
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-disabled: true
- name: Build canonical unsigned APKs and AAB
run: tools/reproducible-builds/build-in-container.sh "$RUNNER_TEMP/release-${{ matrix.replica }}"
- name: Upload unsigned replica
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: unsigned-release-${{ matrix.replica }}
path: ${{ runner.temp }}/release-${{ matrix.replica }}/*
retention-days: 30
if-no-files-found: error
release:
compare:
name: Verify reproducibility
runs-on: ubuntu-24.04
needs: build
runs-on: ubuntu-latest
permissions:
attestations: write
contents: read
id-token: write
steps:
- name: Download APK artifact
uses: actions/download-artifact@v4
- name: Checkout verification script
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
name: bitchat-release-apk-${{ github.ref_name }}
path: .
ref: ${{ env.RELEASE_TAG }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
- name: Download replica A
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
files: bitchat.apk
name: Release ${{ github.ref_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
name: unsigned-release-a
path: ${{ runner.temp }}/release-a
- name: Download replica B
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: unsigned-release-b
path: ${{ runner.temp }}/release-b
- name: Compare every canonical byte
run: tools/reproducible-builds/compare-release.sh "$RUNNER_TEMP/release-a" "$RUNNER_TEMP/release-b"
- name: Prepare public attestation subjects
run: |
mkdir "$RUNNER_TEMP/attestation-subjects"
cp "$RUNNER_TEMP/release-a/BUILDINFO.json" \
"$RUNNER_TEMP/attestation-subjects/BITCHAT_BUILDINFO.json"
cp "$RUNNER_TEMP/release-a/SHA256SUMS.unsigned" \
"$RUNNER_TEMP/attestation-subjects/BITCHAT_SHA256SUMS.unsigned"
cp "$RUNNER_TEMP"/release-a/bitchat-android-*-unsigned.apk \
"$RUNNER_TEMP/attestation-subjects/"
cp "$RUNNER_TEMP"/release-a/bitchat-android-*-unsigned.aab \
"$RUNNER_TEMP/attestation-subjects/"
- name: Attest verified unsigned release
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with:
subject-path: ${{ runner.temp }}/attestation-subjects/*
- name: Upload verified unsigned release
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: verified-unsigned-release
path: ${{ runner.temp }}/release-a/*
retention-days: 30
if-no-files-found: error

21
.gitignore vendored
View File

@ -7,11 +7,13 @@ build/
!*/build/intermediates/
local.properties
.gradle/
.kotlin/
captures/
.externalNativeBuild/
debug_keystore/
*.keystore
debug.keystore
app/release
# Gradle
/build/
@ -39,15 +41,24 @@ dependency-reduced-pom.xml
# Linters
.lint/
# Python test tooling
**/__pycache__/
*.py[cod]
release-gate-results/
# Other
*.log
.cxx/
*build/
# Gradle/Android build directories (but not tools/arti-build/)
**/build/
!tools/arti-build/
out/
gen/
*~
*.swp
*.lock
!tools/arti-build/Cargo.lock
.goosehints
# Google services
google-services.json
@ -55,3 +66,11 @@ google-services.json
# Keystore files
*.jks
*.keystore
# Arti build artifacts (cloned repo and Rust build cache)
tools/arti-build/.arti-source/
tools/arti-build/target/
.reproducible-build/
# JVM heap dumps (a Gradle daemon OOM drops these in the repo root)
*.hprof

1
.java-version Normal file
View File

@ -0,0 +1 @@
21.0.11

127
AGENTS.md Normal file
View File

@ -0,0 +1,127 @@
# Repository Guidelines
## Privacy & External Disclosure (Mandatory)
Protect the developer's privacy without exception. Treat all data from the
developer's computer, accounts, devices, workspace, and communications as
private unless the developer explicitly authorizes sharing a specific item
with a specific destination.
- Never expose, publish, transmit, upload, paste, commit, or otherwise disclose
personally identifying or potentially identifying information. This includes
names, usernames, email addresses, account handles, home or workplace details,
local or absolute paths, drive or volume names, hostnames, device names and
serials, IP or MAC addresses, network names, peer IDs, messages, contacts,
tokens, keys, credentials, logs, metadata, and unique environment details.
- Never post any repository, workspace, device, account, conversation, log, or
developer data to GitHub, issue trackers, pull requests, comments, gists,
paste sites, analytics services, AI services, remote APIs, chat systems, or
any other external destination without the developer's explicit permission
for that exact data and destination. A general request to work on the
repository is not permission to disclose data.
- Keep local filesystem layout and computer information private. Do not include
absolute paths, usernames, home-directory names, mounted drives, shell
prompts, environment variables, installed-software inventories, hardware
details, or similar machine fingerprints in commits, patches, documentation,
screenshots, test fixtures, examples, issue text, PR text, or comments.
- Before any authorized external action, inspect and sanitize the exact content
being sent. Use repository-relative paths and neutral placeholders; remove
hidden metadata and redact unrelated or identifying content. When safe
sanitization cannot be guaranteed, do not send the data and ask the developer
how to proceed.
- Use synthetic, non-identifying test data only. Never use real messages,
contacts, account data, device identifiers, network information, keys, or
production logs in tests, examples, screenshots, fixtures, or bug reports.
- For geohash, GPS, map, location, proximity, or geofencing work, use only
clearly synthetic coordinates and geohashes that cannot reveal the
developer's location or routines. Never request, read, derive, record, use,
display, transmit, or retain live or historical location data from any
physical phone, watch, emulator image, computer, browser, account, or other
device connected to or accessible from the developer's computer. Never infer
location from IP addresses, networks, photos, logs, timestamps, nearby peers,
device metadata, or test output. Treat all location and movement data as
highly sensitive personal information and keep it out of commits, tests,
fixtures, screenshots, documentation, issues, pull requests, and external
services without exception.
- Keep command output, raw logs, crash dumps, screenshots, recordings, build
artifacts, and Mesh Lab evidence local. Review any derived summary for
identifying details before sharing it, even when external sharing has been
authorized.
- Do not weaken these protections for convenience, debugging, automation, or
collaboration. If another instruction conflicts with this section, stop and
obtain explicit developer direction before disclosing anything.
## Project Structure & Architecture
`app/` is the Kotlin/Compose phone client; its main packages cover UI, services,
BLE/Wi-Fi mesh, protocol, Noise/crypto, identity, Nostr, geohash, and media.
`wear/` is the Wear OS client. Module resources live in `src/main/`
and JVM tests in `src/test/`. Specifications are in `docs/`; tooling is in
`tools/`.
`app/` is the source of truth for shared mesh/protocol code.
`syncSharedAppSources` generates `wear/build/sharedSrc` from the include list
in `wear/build.gradle.kts`. Extend that list; never copy shared
Kotlin into `wear/src/` or edit generated `build/` content.
## Build, Test & Development Commands
Use JDK 21 and the Android SDK versions in `gradle/libs.versions.toml`.
```sh
./gradlew :app:assembleDebug :wear:assembleDebug
./gradlew testDebugUnitTest lintDebug
./gradlew connectedAndroidTest
./gradlew clientRewriteContractTest
tools/arti-build/verify-checksums.sh
```
CI runs `testDebugUnitTest lintDebug`; instrumented tests require a device.
Follow `docs/reproducible-builds.md` and `docs/maintainer-release-guide.md` for
dependency and release work.
## Coding Style & Naming
Use official Kotlin style with four-space indentation. Classes and Composables
use `PascalCase`; functions and properties use `camelCase`; constants use
`UPPER_SNAKE_CASE`. Hoist Compose state, expose immutable `StateFlow`, use
structured coroutines and suspend I/O, and never block the main thread.
Protocol and security changes must remain fail-closed and cross-client
compatible. Update the relevant specification and golden-vector tests.
## Testing & Physical Mesh Lab
Tests use JUnit 4, Robolectric, Mockito, and coroutine test utilities. Name files
`*Test.kt` by observable behavior. Avoid arbitrary sleeps, public
relays, live user data, and nondeterministic completion. See
`docs/testing-conventions.md`.
Changes affecting discovery, routing, transports, Noise/crypto, identity,
foreground-service power, messaging, transfers, packets, or fragmentation
require Mesh Lab validation on physical devices. Debug-only hooks live in
`app/src/debug/` and `wear/src/debug/`; never move them into release sources.
```sh
python3 tools/release_gate/mesh_lab.py setup \
--serial-a <device-a> --serial-b <device-b> --apk <debug-apk>
python3 tools/release_gate/mesh_lab.py scenario all \
--serial-a <device-a> --serial-b <device-b> --out /tmp/mesh-evidence
```
For phone-to-watch interop, replace `--serial-b` with `--serial-watch` and add
`--watch-apk`. Keep devices unlocked and awake. Follow the Mesh Lab appendix in
`docs/release-gate-runbook.md`; raw evidence and logcat must remain local.
## Commits, Pull Requests & Privacy
Use short imperative subjects (`Fix stale peer lifecycle cleanup`) or scoped
Conventional Commit subjects (`fix(wifi-aware): ...`). PRs must explain risk,
link the issue, report tests, and include before/after screenshots for visible
phone or watch changes. Do not publish raw device logs.
Use `gh` for GitHub operations. Never override Git author or committer identity.
The mandatory privacy rules above apply to all Git and GitHub activity. Treat a
request to create a commit or pull request as permission only for the sanitized
repository changes and description needed for that action, never for local or
personal metadata. Never commit keystores or secrets.

View File

@ -1,171 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [0.7.2] - 2025-07-20
### Fixed
- fix: battery optimization screen content scrollable with fixed buttons
## [0.7.1] - 2025-07-19
### Added
-feat(battery): add battery optimization management for background reliability
### Fixed
- fix: center align toolbar item in ChatHeader - passed modifier.fillmaxHeight so the content inside the row can actually be centered
- fix: update sidebar text to use string resources
- fix(chat): cursor location and enhance message input with slash command styling
### Changed
- refactor: remove context attribute at ChatViewModel.kt
- Refactor: Migrate MainViewModel to use StateFlow
### Improved
- Use HorizontalDivider instead of deprecated Divider
- Use contentPadding instead of padding so items remain fully visible
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.7]
### Added
- Location services check during app startup with educational UI
- Message text selection functionality in chat interface
- Enhanced RSSI tracking and unread message indicators
- Major Bluetooth connection architecture refactoring with dedicated managers
### Fixed
- **Critical**: Android-iOS message fragmentation compatibility issues
- Fixed fragment size (500→150 bytes) and ID generation for cross-platform messaging
- Ensures Android can properly communicate with iOS devices
- DirectMessage notifications and text copying functionality
- Smart routing optimizations (no relay loops, targeted delivery)
- Build system compilation issues and null pointer exceptions
### Changed
- Comprehensive dependency updates (AGP 8.10.1, Kotlin 2.2.0, Compose 2025.06.01)
- Optimized BLE scan intervals for better battery performance
- Reduced excessive logging output
### Improved
- Cross-platform compatibility with iOS and Rust implementations
- Connection stability through architectural improvements
- Battery performance via scan duty cycling
- User onboarding with location services education
## [0.6]
### Added
- Channel password management with `/pass` command for channel owners
- Monochrome/themed launcher icon for Android 12+ dynamic theming support
- Unit tests package with initial testing infrastructure
- Production build optimization with code minification and shrinking
- Native back gesture/button handling for all app views
### Fixed
- Favorite peer functionality completely restored and improved
- Enhanced favorite system with fallback mechanism for peers without key exchange
- Fixed UI state updates for favorite stars in both header and sidebar
- Improved favorite persistence across app sessions
- `/w` command now displays user nicknames instead of peer IDs
- Button styling and layout improvements across the app
- Enhanced back button positioning and styling
- Improved private chat and channel header button layouts
- Fixed button padding and alignment issues
- Color scheme consistency updates
- Updated orange color throughout the app to match iOS version
- Consistent color usage for private messages and UI elements
- App startup reliability improvements
- Better initialization sequence handling
- Fixed null pointer exceptions during startup
- Enhanced error handling and logging
- Input field styling and behavior improvements
- Sidebar user interaction enhancements
- Permission explanation screen layout fixes with proper vertical padding
### Changed
- Updated GitHub organization references in project files
- Improved README documentation with updated clone URLs
- Enhanced logging throughout the application for better debugging
## [0.5.1] - 2025-07-10
### Added
- Bluetooth startup check with user prompt to enable Bluetooth if disabled
### Fixed
- Improved Bluetooth initialization reliability on first app launch
## [0.5] - 2025-07-10
### Added
- New user onboarding screen with permission explanations
- Educational content explaining why each permission is required
- Privacy assurance messaging (no tracking, no servers, local-only data)
### Fixed
- Comprehensive permission validation - ensures all required permissions are granted
- Proper Bluetooth stack initialization on first app load
- Eliminated need for manual app restart after installation
- Enhanced permission request coordination and error handling
### Changed
- Improved first-time user experience with guided setup flow
## [0.4] - 2025-07-10
### Added
- Push notifications for direct messages
- Enhanced notification system with proper click handling and grouping
### Improved
- Direct message (DM) view with better user interface
- Enhanced private messaging experience
### Known Issues
- Favorite peer functionality currently broken
## [0.3] - 2025-07-09
### Added
- Battery-aware scanning policies for improved power management
- Dynamic scan behavior based on device battery state
### Fixed
- Android-to-Android Bluetooth Low Energy connections
- Peer discovery reliability between Android devices
- Connection stability improvements
## [0.2] - 2025-07-09
### Added
- Initial Android implementation of bitchat protocol
- Bluetooth Low Energy mesh networking
- End-to-end encryption for private messages
- Channel-based messaging with password protection
- Store-and-forward message delivery
- IRC-style commands (/msg, /join, /clear, etc.)
- RSSI-based signal quality indicators
### Fixed
- Various Bluetooth handling improvements
- User interface refinements
- Connection reliability enhancements
## [0.1] - 2025-07-08
### Added
- Initial release of bitchat Android client
- Basic mesh networking functionality
- Core messaging features
- Protocol compatibility with iOS bitchat client
[Unreleased]: https://github.com/permissionlesstech/bitchat-android/compare/0.5.1...HEAD
[0.5.1]: https://github.com/permissionlesstech/bitchat-android/compare/0.5...0.5.1
[0.5]: https://github.com/permissionlesstech/bitchat-android/compare/0.4...0.5
[0.4]: https://github.com/permissionlesstech/bitchat-android/compare/0.3...0.4
[0.3]: https://github.com/permissionlesstech/bitchat-android/compare/0.2...0.3
[0.2]: https://github.com/permissionlesstech/bitchat-android/compare/0.1...0.2
[0.1]: https://github.com/permissionlesstech/bitchat-android/releases/tag/0.1

View File

@ -1,21 +1,674 @@
MIT License
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (c) 2025
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@ -8,8 +8,13 @@ bitchat is designed with privacy as its foundation. We believe private communica
## Summary
**WE DO NOT COLLECT ANY INFORMATION.**
- **No personal data collection** - We don't collect names, emails, or phone numbers
- **No servers** - Everything happens on your device and through peer-to-peer connections
- **No location data collection** - Location is accessed only for local processing (BLE/Geohash) and is never collected or sent to us
- **Hybrid Functionality** - bitchat offers two modes of communication:
- **Bluetooth Mesh Chat**: This mode is completely offline, using peer-to-peer Bluetooth connections. It does not use any servers or internet connection.
- **Geohash Chat**: This mode uses an internet connection to communicate with others in a specific geographic area. It relies on Nostr relays for message transport.
- **No tracking** - We have no analytics, telemetry, or user tracking
- **Open source** - You can verify these claims by reading our code
@ -66,7 +71,8 @@ When you join a password-protected room:
bitchat **never**:
- Collects personal information
- Tracks your location
- Collects location history
- Transmits any data to us (the developers)
- Stores data on servers
- Shares data with third parties
- Uses analytics or telemetry
@ -89,13 +95,24 @@ You have complete control:
- **No Account**: Nothing to delete from servers because there are none
- **Portability**: Your data never leaves your device unless you export it
## Bluetooth & Permissions
## Location Data & Permissions
bitchat requires Bluetooth permission to function:
- Used only for peer-to-peer communication
- No location data is accessed or stored
- Bluetooth is not used for tracking
- You can revoke this permission at any time in system settings
To provide the core functionality of bitchat, we access your device's location data. This access is necessary for the following specific purposes:
### 1. Bluetooth Low Energy (BLE) Scanning
- **Why we need it:** The Android operating system requires Location permission to scan for nearby Bluetooth LE devices (especially on Android 11 and lower). This is a system-level requirement because Bluetooth scans can theoretically be used to derive location.
- **How we use it:** We use this permission strictly to discover other bitchat peers nearby for the "Bluetooth Mesh Chat" mode.
- **Privacy protection:** We do not record or store your location during this process. The data is processed instantaneously by the Android system to facilitate the connection.
### 2. Geohash Chat Functionality
- **Why we need it:** The "Geohash Chat" mode allows you to communicate with others in your approximate geographic area.
- **How we use it:** If you enable this mode, we access your location to calculate a "geohash" (a short alphanumeric string representing a geographic region). This geohash is used to find and subscribe to relevant channels on decentralized Nostr relays.
- **Privacy protection:**
- Your precise GPS coordinates are **never** sent to any server or peer.
- Only the coarse geohash (representing an area, not a pinpoint) is shared with the Nostr network.
- You can use the "Bluetooth Mesh Chat" mode without this feature if you prefer.
**We do not collect, store, or share your location history.** Location data is processed locally on your device to enable these specific features.
## Children's Privacy

338
README.md
View File

@ -1,25 +1,29 @@
<p align="center">
<img src="https://github.com/user-attachments/assets/188c42f8-d249-4a72-b27a-e2b4f10a00a8" alt="Bitchat Android Logo" width="480">
</p>
<img width="256" height="256" alt="icon_128x128@2x" src="https://github.com/user-attachments/assets/90133f83-b4f6-41c6-aab9-25d0859d2a47" />
> [!WARNING]
> This software has not received external security review and may contain vulnerabilities and may not necessarily meet its stated security goals. Do not use it for sensitive use cases, and do not rely on its security until it has been reviewed. Work in progress.
## bitchat for Android
# bitchat for Android
A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers.
A secure, decentralized, peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required, no servers, no phone numbers - just pure encrypted communication.
This is the Android implementation of bitchat, fully protocol-compatible with the [iOS version](https://github.com/permissionlesstech/bitchat) for cross-platform mesh communication.
This is the **Android port** of the original [bitchat iOS app](https://github.com/jackjackbits/bitchat), maintaining 100% protocol compatibility for cross-platform communication.
[bitchat.free](http://bitchat.free)
## Install bitchat
[GitHub Releases](https://github.com/permissionlesstech/bitchat-android/releases)
You can download the latest version of bitchat for Android from the [GitHub Releases page](https://github.com/permissionlesstech/bitchat-android/releases).
[<img alt="Get it on Google Play" height="60" src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"/>](https://play.google.com/store/apps/details?id=com.bitchat.droid)
**Instructions:**
## See it in action
1. **Download the APK:** On your Android device, navigate to the link above and download the latest `.apk` file. Open it.
2. **Allow Unknown Sources:** On some devices, before you can install the APK, you may need to enable "Install from unknown sources" in your device's settings. This is typically found under **Settings > Security** or **Settings > Apps & notifications > Special app access**.
3. **Install:** Open the downloaded `.apk` file to begin the installation.
<table>
<tr>
<th>Offline mesh conversation</th>
<th>Geohash globe picker</th>
</tr>
<tr>
<td><img src="docs/screenshots/readme-mesh-chat.png" alt="Active four-peer Bitchat mesh conversation with an image, voice messages, and text messages" width="360"/></td>
<td><img src="docs/screenshots/readme-geohash-globe.png" alt="Bitchat geohash location picker showing the whole Earth and geohash grid" width="360"/></td>
</tr>
</table>
## License
@ -27,277 +31,75 @@ This project is released into the public domain. See the [LICENSE](LICENSE.md) f
## Features
- **✅ Cross-Platform Compatible**: Full protocol compatibility with iOS bitchat
- **✅ Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **✅ End-to-End Encryption**: X25519 key exchange + AES-256-GCM for private messages
- **✅ Channel-Based Chats**: Topic-based group messaging with optional password protection
- **✅ Store & Forward**: Messages cached for offline peers and delivered when they reconnect
- **✅ Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **✅ IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface
- **✅ Message Retention**: Optional channel-wide message saving controlled by channel owners
- **✅ Emergency Wipe**: Triple-tap logo to instantly clear all data
- **✅ Modern Android UI**: Jetpack Compose with Material Design 3
- **✅ Dark/Light Themes**: Terminal-inspired aesthetic matching iOS version
- **✅ Battery Optimization**: Adaptive scanning and power management
## Android Setup
### Prerequisites
- **Android Studio**: Arctic Fox (2020.3.1) or newer
- **Android SDK**: API level 26 (Android 8.0) or higher
- **Kotlin**: 1.8.0 or newer
- **Gradle**: 7.0 or newer
### Build Instructions
1. **Clone the repository:**
```bash
git clone https://github.com/permissionlesstech/bitchat-android.git
cd bitchat-android
```
2. **Open in Android Studio:**
```bash
# Open Android Studio and select "Open an Existing Project"
# Navigate to the bitchat-android directory
```
3. **Build the project:**
```bash
./gradlew build
```
4. **Install on device:**
```bash
./gradlew installDebug
```
### Development Build
For development builds with debugging enabled:
```bash
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
### Release Build
For production releases:
```bash
./gradlew assembleRelease
```
## Android-Specific Requirements
### Permissions
The app requires the following permissions (automatically requested):
- **Bluetooth**: Core BLE functionality
- **Location**: Required for BLE scanning on Android
- **Notifications**: Message alerts and background updates
### Hardware Requirements
- **Bluetooth LE (BLE)**: Required for mesh networking
- **Android 8.0+**: API level 26 minimum
- **RAM**: 2GB recommended for optimal performance
## Usage
### Basic Commands
- `/j #channel` - Join or create a channel
- `/m @name message` - Send a private message
- `/w` - List online users
- `/channels` - Show all discovered channels
- `/block @name` - Block a peer from messaging you
- `/block` - List all blocked peers
- `/unblock @name` - Unblock a peer
- `/clear` - Clear chat messages
- `/pass [password]` - Set/change channel password (owner only)
- `/transfer @name` - Transfer channel ownership
- `/save` - Toggle message retention for channel (owner only)
### Getting Started
1. **Install the app** on your Android device (requires Android 8.0+)
2. **Grant permissions** for Bluetooth and location when prompted
3. **Launch bitchat** - it will auto-start mesh networking
4. **Set your nickname** or use the auto-generated one
5. **Connect automatically** to nearby iOS and Android bitchat users
6. **Join a channel** with `/j #general` or start chatting in public
7. **Messages relay** through the mesh network to reach distant peers
### Android UI Features
- **Jetpack Compose UI**: Modern Material Design 3 interface
- **Dark/Light Themes**: Terminal-inspired aesthetic matching iOS
- **Haptic Feedback**: Vibrations for interactions and notifications
- **Adaptive Layout**: Optimized for various Android screen sizes
- **Message Status**: Real-time delivery and read receipts
- **RSSI Indicators**: Signal strength colors for each peer
### Channel Features
- **Password Protection**: Channel owners can set passwords with `/pass`
- **Message Retention**: Owners can enable mandatory message saving with `/save`
- **@ Mentions**: Use `@nickname` to mention users (with autocomplete)
- **Ownership Transfer**: Pass control to trusted users with `/transfer`
## Security & Privacy
### Encryption
- **Private Messages**: X25519 key exchange + AES-256-GCM encryption
- **Channel Messages**: Argon2id password derivation + AES-256-GCM
- **Digital Signatures**: Ed25519 for message authenticity
- **Forward Secrecy**: New key pairs generated each session
### Privacy Features
- **No Registration**: No accounts, emails, or phone numbers required
- **Ephemeral by Default**: Messages exist only in device memory
- **Cover Traffic**: Random delays and dummy messages prevent traffic analysis
- **Emergency Wipe**: Triple-tap logo to instantly clear all data
- **Local-First**: Works completely offline, no servers involved
## Performance & Efficiency
### Message Compression
- **LZ4 Compression**: Automatic compression for messages >100 bytes
- **30-70% bandwidth savings** on typical text messages
- **Smart compression**: Skips already-compressed data
### Battery Optimization
- **Adaptive Power Modes**: Automatically adjusts based on battery level
- Performance mode: Full features when charging or >60% battery
- Balanced mode: Default operation (30-60% battery)
- Power saver: Reduced scanning when <30% battery
- Ultra-low power: Emergency mode when <10% battery
- **Background efficiency**: Automatic power saving when app backgrounded
- **Configurable scanning**: Duty cycle adapts to battery state
### Network Efficiency
- **Optimized Bloom filters**: Faster duplicate detection with less memory
- **Message aggregation**: Batches small messages to reduce transmissions
- **Adaptive connection limits**: Adjusts peer connections based on power mode
- **Dual Transport Architecture**: Bluetooth LE mesh for offline messaging, Nostr relays for internet-based messaging
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over Nostr relays
- **Intelligent Message Routing**: Automatically chooses the best transport, with queuing and retry when a peer is unreachable
- **End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) (XX pattern, X25519 + ChaCha20-Poly1305) for private messages over the mesh
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop relay over Bluetooth LE (max 7 hops)
- **Wi-Fi Aware Transport**: Higher-bandwidth local mesh on supported devices
- **Channel Chats**: Topic-based group messaging with optional password protection (Argon2id + AES-256-GCM)
- **IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface
- **Tor Support**: Built-in Tor (Arti) for private internet connectivity
- **Emergency Wipe**: Triple-tap to instantly clear all data
- **Cross-Platform**: Binary protocol compatible with bitchat on iOS and macOS
## Technical Architecture
### Binary Protocol
bitchat uses an efficient binary protocol optimized for Bluetooth LE:
- Compact packet format with 1-byte type field
- TTL-based message routing (max 7 hops)
- Automatic fragmentation for large messages
- Message deduplication via unique IDs
### Bluetooth Mesh Network (Offline)
### Mesh Networking
- Each device acts as both client and peripheral
- Automatic peer discovery and connection management
- Store-and-forward for offline message delivery
- Adaptive duty cycling for battery optimization
- Direct peer-to-peer within Bluetooth range, multi-hop relay through nearby devices
- Noise Protocol sessions with forward secrecy; peer identities derived from static keys
- Compact binary packet format with fragmentation, TTL routing, and deduplication
- Adaptive duty cycling and connection limits for battery efficiency
- Foreground service keeps the mesh alive within Android background execution limits
### Android-Specific Optimizations
- **Coroutine Architecture**: Asynchronous operations for mesh networking
- **Kotlin Coroutines**: Thread-safe concurrent mesh operations
- **EncryptedSharedPreferences**: Secure storage for user settings
- **Lifecycle-Aware**: Proper handling of Android app lifecycle
- **Battery Optimization**: Foreground service and adaptive scanning
### Nostr Protocol (Internet)
## Android Technical Architecture
- Global reach via public relays, geohash-based location channels
- Private messages fall back to Nostr for mutual favorites when the mesh is unavailable
- Ephemeral keys per geohash area
### Core Components
### Android Stack
1. **BitchatApplication.kt**: Application-level initialization and dependency injection
2. **MainActivity.kt**: Main activity handling permissions and UI hosting
3. **ChatViewModel.kt**: MVVM pattern managing app state and business logic
4. **BluetoothMeshService.kt**: Core BLE mesh networking (central + peripheral roles)
5. **EncryptionService.kt**: Cryptographic operations using BouncyCastle
6. **BinaryProtocol.kt**: Binary packet encoding/decoding matching iOS format
7. **ChatScreen.kt**: Jetpack Compose UI with Material Design 3
- Kotlin, Jetpack Compose (Material 3), MVVM
- Coroutines and Flow for all networking and state
- Core components: `MeshForegroundService` (persistent connectivity), `BluetoothMeshService` / `WifiAwareMeshService` (transports), `UnifiedMeshService` (transport selection), `NoiseSessionManager` (encryption sessions), `MessageRouter` (mesh/Nostr routing with outbox retry)
### Dependencies
## Building
- **Jetpack Compose**: Modern declarative UI
- **BouncyCastle**: Cryptographic operations (X25519, Ed25519, AES-GCM)
- **Nordic BLE Library**: Reliable Bluetooth LE operations
- **Kotlin Coroutines**: Asynchronous programming
- **LZ4**: Message compression (when enabled)
- **EncryptedSharedPreferences**: Secure local storage
Requires Android Studio and the Android SDK (API 26+).
### Binary Protocol Compatibility
```bash
git clone https://github.com/permissionlesstech/bitchat-android.git
cd bitchat-android
./gradlew assembleDebug
```
The Android implementation maintains 100% binary protocol compatibility with iOS:
- **Header Format**: Identical 13-byte header structure
- **Packet Types**: Same message types and routing logic
- **Encryption**: Identical cryptographic algorithms and key exchange
- **UUIDs**: Same Bluetooth service and characteristic identifiers
- **Fragmentation**: Compatible message fragmentation for large content
Install on a connected device:
## Publishing to Google Play
```bash
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
### Preparation
The app requests Bluetooth, location (required for BLE scanning), and notification permissions at runtime.
1. **Update version information:**
```kotlin
// In app/build.gradle.kts
defaultConfig {
versionCode = 2 // Increment for each release
versionName = "1.1.0" // User-visible version
}
```
Release APKs and the Android App Bundle can be rebuilt byte-for-byte in the
pinned Linux container. Maintainers should follow the
[Android release guide](docs/maintainer-release-guide.md). See
[Reproducible builds](docs/reproducible-builds.md) for the build trust model
and public GitHub/Google Play verification procedures.
2. **Create a signed release build:**
```bash
./gradlew assembleRelease
```
## Testing
3. **Generate app bundle (recommended for Play Store):**
```bash
./gradlew bundleRelease
```
```bash
# Unit tests
./gradlew test
### Play Store Requirements
# Lint
./gradlew lint
- **Target API**: Latest Android API (currently 34)
- **Privacy Policy**: Required for apps requesting sensitive permissions
- **App Permissions**: Justify Bluetooth and location usage
- **Content Rating**: Complete questionnaire for age-appropriate content
# Instrumented tests (requires a device or emulator)
./gradlew connectedAndroidTest
```
### Distribution
- **Google Play Store**: Main distribution channel
- **F-Droid**: For open-source distribution
- **Direct APK**: For testing and development
## Cross-Platform Communication
This Android port enables seamless communication with the original iOS bitchat app:
- **iPhone ↔ Android**: Full bidirectional messaging
- **Mixed Groups**: iOS and Android users in same channels
- **Feature Parity**: All commands and encryption work across platforms
- **Protocol Sync**: Identical message format and routing behavior
**iOS Version**: For iPhone/iPad users, get the original bitchat at [github.com/jackjackbits/bitchat](https://github.com/jackjackbits/bitchat)
## Contributing
Contributions are welcome! Key areas for enhancement:
1. **Performance**: Battery optimization and connection reliability
2. **UI/UX**: Additional Material Design 3 features
3. **Security**: Enhanced cryptographic features
4. **Testing**: Unit and integration test coverage
5. **Documentation**: API documentation and development guides
## Support & Issues
- **Bug Reports**: [Create an issue](../../issues) with device info and logs
- **Feature Requests**: [Start a discussion](../../discussions)
- **Security Issues**: Email security concerns privately
- **iOS Compatibility**: Cross-reference with [original iOS repo](https://github.com/jackjackbits/bitchat)
For iOS-specific issues, please refer to the [original iOS bitchat repository](https://github.com/jackjackbits/bitchat).
Note that BLE mesh behavior is difficult to emulate; protocol and session logic is covered by unit tests, while radio-level behavior needs real devices.

View File

@ -1,20 +1,42 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.compose)
}
val githubReleaseCertSha256 = providers
.environmentVariable("BITCHAT_GITHUB_RELEASE_CERT_SHA256")
.orElse(providers.gradleProperty("BITCHAT_GITHUB_RELEASE_CERT_SHA256"))
.orElse("")
val normalizedGithubReleaseCertSha256 = githubReleaseCertSha256.get()
.replace(":", "")
.trim()
.lowercase()
require(
normalizedGithubReleaseCertSha256.isEmpty() ||
normalizedGithubReleaseCertSha256.matches(Regex("[a-f0-9]{64}"))
) {
"BITCHAT_GITHUB_RELEASE_CERT_SHA256 must be a SHA-256 certificate fingerprint"
}
android {
namespace = "com.bitchat.android"
compileSdk = libs.versions.compileSdk.get().toInt()
buildToolsVersion = libs.versions.buildTools.get()
defaultConfig {
applicationId = "com.bitchat.android"
applicationId = "com.bitchat.droid"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 6
versionName = "0.8"
versionCode = 38
versionName = "2.0.1"
buildConfigField(
"String",
"GITHUB_RELEASE_CERT_SHA256",
"\"$normalizedGithubReleaseCertSha256\""
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -22,7 +44,20 @@ android {
}
}
dependenciesInfo {
// Disables dependency metadata when building APKs.
includeInApk = false
// Disables dependency metadata when building Android App Bundles.
includeInBundle = false
}
buildTypes {
debug {
ndk {
// Include x86_64 for emulator support during development
abiFilters += listOf("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
}
}
release {
isMinifyEnabled = true
isShrinkResources = true
@ -30,17 +65,39 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
vcsInfo {
// BUILDINFO.json and attestations carry the verified commit
// without depending on host-specific Git/worktree paths.
include = false
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
// APK splits for GitHub releases - creates arm64, x86_64, and universal APKs
// AAB for Play Store handles architecture distribution automatically
// Auto-detects: splits enabled for assemble tasks, disabled for bundle tasks
// Works in Android Studio GUI and CLI without needing extra properties
val enableSplits = gradle.startParameter.taskNames.any { taskName ->
taskName.contains("assemble", ignoreCase = true) &&
!taskName.contains("bundle", ignoreCase = true)
}
kotlinOptions {
jvmTarget = "1.8"
splits {
abi {
isEnable = enableSplits
reset()
include("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
isUniversalApk = true // For F-Droid and fallback
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
@ -54,6 +111,20 @@ android {
}
}
composeCompiler {
// Kotlin 2.4.10's optional Compose group-key mapping depends on unspecified
// class-file iteration order. Keep the normal R8 mapping, but omit that
// augmentation until its producer is deterministic across clean builds.
includeComposeMappingFile.set(false)
}
kotlin {
jvmToolchain(21)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
dependencies {
// Core Android dependencies
implementation(libs.androidx.core.ktx)
@ -66,12 +137,22 @@ dependencies {
// Lifecycle
implementation(libs.bundles.lifecycle)
implementation(libs.androidx.lifecycle.process)
// Navigation
implementation(libs.androidx.navigation.compose)
// Permissions
implementation(libs.accompanist.permissions)
// QR
implementation(libs.zxing.core)
implementation(libs.mlkit.barcode.scanning)
// CameraX
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.compose)
// Cryptography
implementation(libs.bundles.cryptography)
@ -84,16 +165,43 @@ dependencies {
// Bluetooth
implementation(libs.nordic.ble)
// Compression
implementation(libs.lz4.java)
// WebSocket
implementation(libs.okhttp)
// WorkManager for background APK downloads
implementation(libs.androidx.work.runtime.ktx)
// HTTP Server for hotspot APK sharing
implementation(libs.nanohttpd)
// Arti (Tor in Rust) Android bridge - custom build from latest source
// Built with rustls, 16KB page size support, and onio//un service client
// Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar)
// Only included in tor flavor to reduce APK size for standard builds
// Note: AAR is kept in libs/ for reference, but libraries loaded from jniLibs/
// Google Play Services Location
implementation(libs.gms.location)
// Security preferences
implementation(libs.androidx.security.crypto)
// EXIF orientation handling for images
implementation(libs.androidx.exifinterface)
// Testing
testImplementation(libs.bundles.testing)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.bundles.compose.testing)
debugImplementation(libs.androidx.compose.ui.tooling)
}
// Robolectric resolves Android runtime jars itself (outside Gradle dependency resolution).
// Its legacy repo1 endpoint rejects cold GitHub-hosted runners with HTTP 403.
tasks.withType<org.gradle.api.tasks.testing.Test>().configureEach {
systemProperty(
"robolectric.dependency.repo.url",
"https://repo.maven.apache.org/maven2"
)
}

464
app/gradle.lockfile Normal file
View File

@ -0,0 +1,464 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
# To regenerate this file, run: ./gradlew :app:dependencies --write-locks
androidx.activity:activity-compose:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.activity:activity-ktx:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.activity:activity:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation-jvm:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.annotation:annotation:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.appcompat:appcompat-resources:1.7.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.appcompat:appcompat:1.7.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.arch.core:core-runtime:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera.featurecombinationquery:featurecombinationquery:1.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera.viewfinder:viewfinder-compose:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera.viewfinder:viewfinder-core:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-camera2-pipe:1.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-camera2:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-compose:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-core:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.camera:camera-lifecycle:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.collection:collection-ktx:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation-core-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation-core:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.animation:animation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation-layout-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation-layout:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.foundation:foundation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material3:material3-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material3:material3:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material:material-icons-core-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath
androidx.compose.material:material-icons-core-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath
androidx.compose.material:material-icons-core:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material:material-icons-extended-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath
androidx.compose.material:material-icons-extended-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath
androidx.compose.material:material-icons-extended:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material:material-ripple-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.material:material-ripple:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-annotation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-annotation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-retain-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-retain:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-saveable-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime-saveable:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.runtime:runtime:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-geometry-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-geometry:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-graphics-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-graphics:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-test-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-junit4:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test-manifest:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-test:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.compose.ui:ui-text-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-text:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-tooling-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.compose.ui:ui-tooling-data-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.compose.ui:ui-tooling-data:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.compose.ui:ui-tooling-preview-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-tooling-preview:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-tooling:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.compose.ui:ui-unit-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-unit:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-util-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui-util:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose.ui:ui:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.compose:compose-bom:2026.06.01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures-ktx:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures-ktx:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.concurrent:concurrent-futures:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.core:core-backported-fixes:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core-ktx:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.core:core:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.documentfile:documentfile:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.drawerlayout:drawerlayout:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.dynamicanimation:dynamicanimation:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.emoji2:emoji2-views-helper:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.exifinterface:exifinterface:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.fragment:fragment:1.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.interpolator:interpolator:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.legacy:legacy-support-core-utils:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-common-java8:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-common-jvm:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-common:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-livedata-core:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-livedata:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-process:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-compose:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-ktx-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-runtime:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-service:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.lifecycle:lifecycle-viewmodel:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-common-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-common:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-compose-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-compose:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-runtime-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigation:navigation-runtime:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-compose-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent-compose:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.navigationevent:navigationevent:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.print:print:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.resourceinspection:resourceinspection-annotation:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.room:room-common:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.room:room-ktx:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.room:room-runtime:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate-compose-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate-compose:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate-ktx:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.savedstate:savedstate:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.security:security-crypto:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.sqlite:sqlite-framework:2.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.sqlite:sqlite:2.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.startup:startup-runtime:1.1.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath
androidx.startup:startup-runtime:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.test.espresso:espresso-core:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.espresso:espresso-core:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test.espresso:espresso-idling-resource:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.espresso:espresso-idling-resource:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test.ext:junit:1.1.5=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.ext:junit:1.3.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test.services:storage:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test.services:storage:1.6.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test:annotation:1.0.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:core:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:core:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test:monitor:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:monitor:1.8.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.test:runner:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
androidx.test:runner:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
androidx.tracing:tracing-android:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.tracing:tracing-ktx:1.3.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.tracing:tracing:1.0.0=debugAndroidTestCompileClasspath
androidx.tracing:tracing:1.1.0=debugUnitTestCompileClasspath
androidx.tracing:tracing:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.transition:transition:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.vectordrawable:vectordrawable-animated:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.vectordrawable:vectordrawable:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.viewpager:viewpager:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.window:window-core-android:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.window:window-core:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.window:window:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.work:work-runtime-ktx:2.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
androidx.work:work-runtime:2.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.almworks.sqlite4java:sqlite4java:1.0.392=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.android.tools.analytics-library:protos:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools.analytics-library:shared:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools.analytics-library:tracker:32.3.1=androidLintTool
com.android.tools.build:aapt2-proto:9.3.1-15703166=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools.build:builder-model:9.3.1=androidLintTool
com.android.tools.build:manifest-merger:32.3.1=androidLintTool
com.android.tools.ddms:ddmlib:32.3.1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.android.tools.emulator:proto:32.3.1=unified-test-platform-android-test-plugin-host-emulator-control
com.android.tools.external.com-intellij:intellij-core:32.3.1=androidLintTool
com.android.tools.external.com-intellij:kotlin-compiler:32.3.1=androidLintTool
com.android.tools.external.org-jetbrains:uast:32.3.1=androidLintTool
com.android.tools.layoutlib:layoutlib-api:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools.lint:lint-api:32.3.1=androidLintTool
com.android.tools.lint:lint-checks:32.3.1=androidLintTool
com.android.tools.lint:lint-gradle:32.3.1=androidLintTool
com.android.tools.lint:lint-model:32.3.1=androidLintTool
com.android.tools.lint:lint-typedef-remover:32.3.1=androidLintTool
com.android.tools.lint:lint:32.3.1=androidLintTool
com.android.tools.utp:android-device-provider-ddmlib-proto:32.3.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-gradle-work-action
com.android.tools.utp:android-device-provider-ddmlib:32.3.1=unified-test-platform-android-device-provider-ddmlib
com.android.tools.utp:android-test-plugin-host-additional-test-output-proto:32.3.1=unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-gradle-work-action
com.android.tools.utp:android-test-plugin-host-additional-test-output:32.3.1=unified-test-platform-android-test-plugin-host-additional-test-output
com.android.tools.utp:android-test-plugin-host-apk-installer-proto:32.3.1=unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-gradle-work-action
com.android.tools.utp:android-test-plugin-host-apk-installer:32.3.1=unified-test-platform-android-test-plugin-host-apk-installer
com.android.tools.utp:android-test-plugin-host-coverage-proto:32.3.1=unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-gradle-work-action
com.android.tools.utp:android-test-plugin-host-coverage:32.3.1=unified-test-platform-android-test-plugin-host-coverage
com.android.tools.utp:android-test-plugin-host-device-info-proto:32.3.1=unified-test-platform-android-test-plugin-host-device-info
com.android.tools.utp:android-test-plugin-host-device-info:32.3.1=unified-test-platform-android-test-plugin-host-device-info
com.android.tools.utp:android-test-plugin-host-emulator-control-proto:32.3.1=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action
com.android.tools.utp:android-test-plugin-host-emulator-control:32.3.1=unified-test-platform-android-test-plugin-host-emulator-control
com.android.tools.utp:android-test-plugin-host-logcat-proto:32.3.1=unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-gradle-work-action
com.android.tools.utp:android-test-plugin-host-logcat:32.3.1=unified-test-platform-android-test-plugin-host-logcat
com.android.tools.utp:android-test-plugin-result-listener-gradle-proto:32.3.1=unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.android.tools.utp:android-test-plugin-result-listener-gradle:32.3.1=unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools.utp:gradle-work-action:32.3.1=unified-test-platform-gradle-work-action
com.android.tools.utp:utp-common:32.3.1=unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat
com.android.tools:annotations:32.3.1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.android.tools:common:32.3.1=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.android.tools:dvlib:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools:play-sdk-proto:32.3.1=androidLintTool
com.android.tools:repository:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools:sdk-common:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.android.tools:sdklib:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.google.accompanist:accompanist-permissions:0.37.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.datatransport:transport-api:2.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.datatransport:transport-backend-cct:2.3.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.datatransport:transport-runtime:2.2.6=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.gms:play-services-base:18.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.gms:play-services-basement:18.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.gms:play-services-location:21.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.gms:play-services-tasks:18.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android.odml:image:1.0.0-beta1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.android:annotations:4.1.1.4=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-core
com.google.api.grpc:proto-google-common-protos:2.17.0=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core
com.google.api.grpc:proto-google-common-protos:2.48.0=unified-test-platform-android-test-plugin-host-emulator-control
com.google.auto.service:auto-service-annotations:1.1.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle
com.google.auto.service:auto-service:1.1.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle
com.google.auto.value:auto-value-annotations:1.11.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.google.auto.value:auto-value-annotations:1.6.3=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.auto:auto-common:1.2.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle
com.google.code.findbugs:jsr305:3.0.2=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
com.google.code.gson:gson:2.10.1=unified-test-platform-core,unified-test-platform-gradle-work-action
com.google.code.gson:gson:2.11.0=androidLintTool,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-result-listener-gradle
com.google.code.gson:gson:2.14.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.code.gson:gson:2.8.9=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher
com.google.crypto.tink:tink-android:1.23.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.crypto.tink:tink:1.18.0=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action
com.google.dagger:dagger:2.48=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action
com.google.dagger:dagger:2.59=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.errorprone:error_prone_annotation:2.38.0=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.23.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.google.errorprone:error_prone_annotations:2.48.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.firebase:firebase-annotations:16.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.firebase:firebase-components:16.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.firebase:firebase-encoders-json:17.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.firebase:firebase-encoders:16.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.guava:failureaccess:1.0.1=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
com.google.guava:failureaccess:1.0.2=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.google.guava:failureaccess:1.0.3=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.google.guava:guava:32.0.1-jre=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
com.google.guava:guava:33.4.0-jre=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.google.guava:guava:33.4.8-jre=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
com.google.j2objc:j2objc-annotations:2.8=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.google.jimfs:jimfs:1.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.google.mlkit:barcode-scanning-common:17.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.mlkit:barcode-scanning:17.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.mlkit:common:18.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.mlkit:vision-common:17.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.mlkit:vision-interfaces:16.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.google.protobuf:protobuf-java-util:3.22.3=unified-test-platform-core
com.google.protobuf:protobuf-java-util:4.28.3=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher
com.google.protobuf:protobuf-java:3.25.5=androidLintTool
com.google.protobuf:protobuf-java:4.28.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
com.google.protobuf:protobuf-kotlin:4.28.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
com.google.testing.platform:android-device-provider-local:0.0.9-alpha04=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle
com.google.testing.platform:android-driver-instrumentation:0.0.9-alpha04=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control
com.google.testing.platform:android-test-plugin:0.0.9-alpha04=unified-test-platform-android-test-plugin
com.google.testing.platform:core-proto:0.0.9-alpha04=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
com.google.testing.platform:core:0.0.9-alpha04=unified-test-platform-core
com.google.testing.platform:launcher:0.0.9-alpha04=unified-test-platform-gradle-work-action,unified-test-platform-launcher
com.google.testparameterinjector:test-parameter-injector:1.18=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.google.zxing:core:3.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.ibm.icu:icu4j:77.1=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.squareup.okhttp3:mockwebserver3:5.4.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
com.squareup.okhttp3:okhttp-android:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.squareup.okhttp3:okhttp:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.squareup.okio:okio-jvm:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.squareup.okio:okio:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
com.squareup:javawriter:2.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
com.sun.xml.fastinfoset:FastInfoset:1.2.16=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
commons-codec:commons-codec:1.17.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
commons-io:commons-io:2.16.1=androidLintTool,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
commons-logging:commons-logging:1.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
io.grpc:grpc-api:1.57.2=unified-test-platform-core
io.grpc:grpc-api:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.grpc:grpc-context:1.57.2=unified-test-platform-core
io.grpc:grpc-context:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.grpc:grpc-core:1.57.2=unified-test-platform-core
io.grpc:grpc-core:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.grpc:grpc-netty:1.57.2=unified-test-platform-core
io.grpc:grpc-netty:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.grpc:grpc-protobuf-lite:1.57.2=unified-test-platform-core
io.grpc:grpc-protobuf-lite:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.grpc:grpc-protobuf:1.57.2=unified-test-platform-core
io.grpc:grpc-protobuf:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.grpc:grpc-services:1.57.2=unified-test-platform-core
io.grpc:grpc-stub:1.57.2=unified-test-platform-core
io.grpc:grpc-stub:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.grpc:grpc-util:1.69.1=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-buffer:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-buffer:4.1.93.Final=unified-test-platform-core
io.netty:netty-codec-http2:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-codec-http2:4.1.93.Final=unified-test-platform-core
io.netty:netty-codec-http:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-codec-http:4.1.93.Final=unified-test-platform-core
io.netty:netty-codec-socks:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-codec-socks:4.1.93.Final=unified-test-platform-core
io.netty:netty-codec:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-codec:4.1.93.Final=unified-test-platform-core
io.netty:netty-common:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-common:4.1.93.Final=unified-test-platform-core
io.netty:netty-handler-proxy:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-handler-proxy:4.1.93.Final=unified-test-platform-core
io.netty:netty-handler:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-handler:4.1.93.Final=unified-test-platform-core
io.netty:netty-resolver:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-resolver:4.1.93.Final=unified-test-platform-core
io.netty:netty-transport-native-unix-common:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-transport-native-unix-common:4.1.93.Final=unified-test-platform-core
io.netty:netty-transport:4.1.110.Final=unified-test-platform-android-test-plugin-host-emulator-control
io.netty:netty-transport:4.1.93.Final=unified-test-platform-core
io.opencensus:opencensus-api:0.31.0=unified-test-platform-core
io.opencensus:opencensus-proto:0.2.0=unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
io.perfmark:perfmark-api:0.26.0=unified-test-platform-core
io.perfmark:perfmark-api:0.27.0=unified-test-platform-android-test-plugin-host-emulator-control
jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
jakarta.inject:jakarta.inject-api:2.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
javax.annotation:javax.annotation-api:1.3.2=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,unified-test-platform-android-test-plugin-host-emulator-control
javax.inject:javax.inject:1=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action
junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
net.bytebuddy:byte-buddy-agent:1.17.7=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.7=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
net.java.dev.jna:jna-platform:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
net.java.dev.jna:jna:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
net.sf.kxml:kxml2:2.3.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
no.nordicsemi.android:ble:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.apache.commons:commons-compress:1.27.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.apache.commons:commons-lang3:3.16.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.apache.httpcomponents:httpclient:4.5.6=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.apache.httpcomponents:httpcore:4.4.16=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.apache.httpcomponents:httpmime:4.5.6=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.bouncycastle:bcpkix-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.bouncycastle:bcprov-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.bouncycastle:bcprov-jdk18on:1.85=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.bouncycastle:bcutil-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.checkerframework:checker-qual:3.33.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
org.checkerframework:checker-qual:3.43.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
org.codehaus.groovy:groovy:3.0.22=androidLintTool
org.codehaus.mojo:animal-sniffer-annotations:1.23=unified-test-platform-core
org.codehaus.mojo:animal-sniffer-annotations:1.24=unified-test-platform-android-test-plugin-host-emulator-control
org.conscrypt:conscrypt-openjdk-uber:2.5.2=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.glassfish.jaxb:txw2:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.hamcrest:hamcrest-integration:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath
org.hamcrest:hamcrest-library:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jetbrains.kotlin:compose-group-mapping:2.4.10=composeMappingProducerClasspath
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-api:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-compiler-runner:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.4.10=kotlin-extension,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-daemon-client:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-parcelize-compiler:2.4.10=kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-parcelize-runtime:2.4.10=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugRuntimeClasspathCopy,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseRuntimeClasspathCopy
org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-reflect:1.8.21=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
org.jetbrains.kotlin:kotlin-reflect:2.1.20=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-script-runtime:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlin:kotlin-stdlib-common:1.8.21=unified-test-platform-android-test-plugin,unified-test-platform-core
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher
org.jetbrains.kotlin:kotlin-stdlib-common:2.2.10=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib-common:2.4.10=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.20=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.10=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.10=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib:1.8.21=unified-test-platform-android-test-plugin,unified-test-platform-core
org.jetbrains.kotlin:kotlin-stdlib:1.9.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher
org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action
org.jetbrains.kotlin:kotlin-stdlib:2.2.21=composeMappingProducerClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath
org.jetbrains.kotlin:kotlin-stdlib:2.4.10=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugRuntimeClasspathCopy,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseRuntimeClasspathCopy
org.jetbrains.kotlin:kotlin-tooling-core:2.4.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath
org.jetbrains.kotlinx:atomicfu-jvm:0.22.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher
org.jetbrains.kotlinx:atomicfu-jvm:0.28.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:atomicfu:0.22.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher
org.jetbrains.kotlinx:atomicfu:0.28.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jetbrains:annotations:13.0=composeMappingProducerClasspath,debugRuntimeClasspathCopy,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,releaseRuntimeClasspathCopy
org.jetbrains:annotations:23.0.0=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher
org.jspecify:jspecify:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.jvnet.staxex:stax-ex:1.8.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle
org.mockito.kotlin:mockito-kotlin:6.3.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.mockito:mockito-core:5.23.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.nanohttpd:nanohttpd:2.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath
org.objenesis:objenesis:3.3=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.ow2.asm:asm-analysis:9.9=androidLintTool
org.ow2.asm:asm-commons:9.8=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.ow2.asm:asm-commons:9.9=androidLintTool
org.ow2.asm:asm-tree:9.8=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.ow2.asm:asm-tree:9.9=androidLintTool
org.ow2.asm:asm-tree:9.9.1=composeMappingProducerClasspath
org.ow2.asm:asm:9.8=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.ow2.asm:asm:9.9=androidLintTool
org.ow2.asm:asm:9.9.1=composeMappingProducerClasspath
org.robolectric:annotations:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:junit:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:nativeruntime-dist-compat:1.0.17=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:nativeruntime:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:pluginapi:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:plugins-maven-dependency-resolver:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:resources:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:robolectric:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:sandbox:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:shadowapi:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:shadows-framework:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:utils-reflector:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.robolectric:utils:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
org.yaml:snakeyaml:2.4=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath
empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues

View File

@ -243,17 +243,6 @@
column="35"/>
</issue>
<issue
id="OldTargetApi"
message="Not targeting the latest versions of Android; compatibility modes apply. Consider testing and updating this version. Consult the android.os.Build.VERSION_CODES javadoc for details."
errorLine1=" targetSdk = 34"
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="build.gradle.kts"
line="14"
column="9"/>
</issue>
<issue
id="RedundantLabel"
message="Redundant label can be removed"

View File

@ -5,3 +5,36 @@
-keep class com.bitchat.android.crypto.** { *; }
-dontwarn org.bouncycastle.**
-keep class org.bouncycastle.** { *; }
# Keep SecureIdentityStateManager from being obfuscated to prevent reflection issues
-keep class com.bitchat.android.identity.SecureIdentityStateManager {
private android.content.SharedPreferences prefs;
*;
}
# Keep all classes that might use reflection
-keep class com.bitchat.android.favorites.** { *; }
-keep class com.bitchat.android.nostr.** { *; }
-keep class com.bitchat.android.identity.** { *; }
# Room loads generated database implementations by name and invokes their no-argument
# constructors reflectively. R8 full-mode can otherwise optimize away WorkDatabase_Impl's
# constructor, causing AndroidX Startup to crash before Application.onCreate.
-keepclassmembers class * extends androidx.room.RoomDatabase {
<init>();
}
# Keep Tor implementation (always included)
-keep class com.bitchat.android.net.RealTorProvider { *; }
# Arti (Custom Tor implementation in Rust) ProGuard rules
-keep class info.guardianproject.arti.** { *; }
-keep class org.torproject.arti.** { *; }
-keepnames class org.torproject.arti.**
-dontwarn info.guardianproject.arti.**
-dontwarn org.torproject.arti.**
# Fix for AbstractMethodError on API < 29 where LocationListener methods are abstract
-keepclassmembers class * implements android.location.LocationListener {
public <methods>;
}

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<!-- Debug-only ADB test hook. Drives mesh operations (scan, connect,
handshake, DMs, files, broadcast, raw packets) from host scripts.
Exported intentionally so `adb shell am broadcast` can reach it;
never shipped in release builds. -->
<receiver
android:name="com.bitchat.android.testhook.TestHookReceiver"
android:exported="true"
tools:ignore="ExportedReceiver">
<intent-filter>
<action android:name="com.bitchat.droid.TEST_HOOK" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@ -0,0 +1,142 @@
package com.bitchat.android.testhook
import android.media.MediaCodec
import android.media.MediaExtractor
import java.io.File
import java.nio.ByteOrder
import kotlin.math.sqrt
internal data class PttTestAudioAnalysis(
val decodedSamples: Long,
val rms: Double,
val silentBlockFraction: Double,
val longestSilentBlockRun: Int,
val zeroCrossingsPerSecond: Double
)
/** Debug-only objective check of the exact ADTS stream assembled by live PTT. */
internal object PttTestAudioAnalyzer {
private const val BLOCK_SAMPLES = 1_024
private const val SILENT_BLOCK_RMS = 0.015
private const val SAMPLE_RATE = 16_000.0
fun analyze(file: File): PttTestAudioAnalysis {
val extractor = MediaExtractor()
var decoder: MediaCodec? = null
try {
extractor.setDataSource(file.absolutePath)
val track = (0 until extractor.trackCount).firstOrNull { index ->
extractor.getTrackFormat(index).getString("mime")?.startsWith("audio/") == true
} ?: error("received live stream has no audio track")
extractor.selectTrack(track)
val format = extractor.getTrackFormat(track)
val mime = format.getString("mime") ?: error("received live stream has no audio MIME")
val activeDecoder = MediaCodec.createDecoderByType(mime).apply {
configure(format, null, null, 0)
start()
}
decoder = activeDecoder
val info = MediaCodec.BufferInfo()
var inputEnded = false
var outputEnded = false
var idlePolls = 0
var decodedSamples = 0L
var sumSquares = 0.0
var zeroCrossings = 0L
var previousSample: Short? = null
var blockSquares = 0.0
var blockSamples = 0
var blocks = 0
var silentBlocks = 0
var silentRun = 0
var longestSilentRun = 0
while (!outputEnded && idlePolls < 500) {
if (!inputEnded) {
val inputIndex = activeDecoder.dequeueInputBuffer(10_000L)
if (inputIndex >= 0) {
val input = activeDecoder.getInputBuffer(inputIndex) ?: error("null decoder input")
input.clear()
val size = extractor.readSampleData(input, 0)
if (size < 0) {
activeDecoder.queueInputBuffer(
inputIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputEnded = true
} else {
activeDecoder.queueInputBuffer(inputIndex, 0, size, extractor.sampleTime, 0)
extractor.advance()
}
}
}
when (val outputIndex = activeDecoder.dequeueOutputBuffer(info, 10_000L)) {
MediaCodec.INFO_TRY_AGAIN_LATER -> idlePolls++
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> idlePolls = 0
else -> if (outputIndex >= 0) {
idlePolls = 0
activeDecoder.getOutputBuffer(outputIndex)?.let { output ->
output.position(info.offset)
output.limit(info.offset + info.size)
output.order(ByteOrder.LITTLE_ENDIAN)
val pcm = output.asShortBuffer()
while (pcm.hasRemaining()) {
val sample = pcm.get()
val normalized = sample.toDouble() / Short.MAX_VALUE.toDouble()
val square = normalized * normalized
sumSquares += square
blockSquares += square
decodedSamples++
blockSamples++
previousSample?.let { previous ->
if ((previous < 0 && sample >= 0) || (previous >= 0 && sample < 0)) {
zeroCrossings++
}
}
previousSample = sample
if (blockSamples == BLOCK_SAMPLES) {
val blockRms = sqrt(blockSquares / blockSamples)
blocks++
if (blockRms < SILENT_BLOCK_RMS) {
silentBlocks++
silentRun++
longestSilentRun = maxOf(longestSilentRun, silentRun)
} else {
silentRun = 0
}
blockSquares = 0.0
blockSamples = 0
}
}
}
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
activeDecoder.releaseOutputBuffer(outputIndex, false)
}
}
}
if (!outputEnded) error("received live stream decoder did not finish")
if (decodedSamples == 0L) error("received live stream decoded to no PCM")
if (blockSamples > 0) {
val blockRms = sqrt(blockSquares / blockSamples)
blocks++
if (blockRms < SILENT_BLOCK_RMS) {
silentBlocks++
silentRun++
longestSilentRun = maxOf(longestSilentRun, silentRun)
}
}
return PttTestAudioAnalysis(
decodedSamples = decodedSamples,
rms = sqrt(sumSquares / decodedSamples),
silentBlockFraction = if (blocks == 0) 1.0 else silentBlocks.toDouble() / blocks,
longestSilentBlockRun = longestSilentRun,
zeroCrossingsPerSecond = zeroCrossings * SAMPLE_RATE / decodedSamples
)
} finally {
runCatching { decoder?.stop() }
runCatching { decoder?.release() }
extractor.release()
}
}
}

View File

@ -0,0 +1,733 @@
package com.bitchat.android.testhook
import android.content.Context
import android.content.Intent
import android.util.Log
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.features.file.FileUtils
import com.bitchat.android.features.voice.LiveVoiceEvent
import com.bitchat.android.features.voice.LiveVoiceManager
import com.bitchat.android.features.voice.LiveVoicePreferences
import com.bitchat.android.features.voice.LiveVoiceScope
import com.bitchat.android.features.voice.LiveVoiceTarget
import com.bitchat.android.features.voice.LiveVoiceCapture
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PrivateMediaPreparation
import com.bitchat.android.mesh.TransferProgressManager
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.service.MeshForegroundService
import com.bitchat.android.service.MeshServiceHolder
import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.ui.DataManager
import com.bitchat.android.ui.PrivateMediaRecipientResolver
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.security.MessageDigest
/**
* Headless engine behind [TestHookReceiver]. Drives the public [MeshService] API and
* observes state via [AppStateStore] flows (never touches the single-slot mesh delegate).
*/
object TestHookDriver {
private const val TAG = TestHookReceiver.TAG
private const val DEFAULT_SCAN_TIMEOUT_MS = 30_000L
private const val DEFAULT_CONNECT_TIMEOUT_MS = 30_000L
private const val DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000L
private const val DEFAULT_RECV_TIMEOUT_MS = 60_000L
private const val DEFAULT_FILE_TIMEOUT_MS = 180_000L
suspend fun execute(context: Context, cmd: String, intent: Intent): JSONObject {
Log.d(TAG, "execute cmd=$cmd")
val result = when (cmd) {
"ping" -> ok(cmd).put("pong", true).put("package", context.packageName)
"start" -> start(context)
"stop" -> stop(context)
"whoami" -> whoami(context)
"set_nickname" -> setNickname(context, intent.requiredString("name"))
"scan" -> scan(context, intent)
"peers" -> peers(context)
"connect" -> connect(intent.requiredString("peer"), intent)
"handshake" -> handshake(context, intent.requiredString("peer"), intent)
"session" -> session(context, intent.requiredString("peer"))
"announce" -> announce(context)
"broadcast_msg" -> broadcastMsg(context, intent.requiredString("content"), intent.getStringExtra("channel"))
"dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id"))
"dm_recv" -> dmRecv(context, intent)
"msg_recv" -> msgRecv(context, intent)
"favorite_set" -> favoriteSet(
context,
intent.requiredString("peer"),
intent.getBooleanExtra("enabled", true)
)
"favorite_status" -> favoriteStatus(context, intent.requiredString("peer"))
"verification_set" -> verificationSet(
context,
intent.requiredString("peer"),
intent.getBooleanExtra("enabled", true)
)
"verification_status" -> verificationStatus(context, intent.requiredString("peer"))
"file_send" -> fileSend(context, intent)
"file_recv" -> fileRecv(context, intent)
"file_cancel" -> fileCancel(context, intent.requiredString("transfer_id"))
"ptt_send" -> pttSend(context, intent)
"ptt_recv" -> pttRecv(context, intent)
"raw_send" -> rawSend(context, intent)
"ble" -> setBle(intent.getBooleanExtra("enabled", true))
"inject_peers" -> injectPeers(intent.getStringExtra("peers"))
"state" -> state(context)
"clear_results" -> clearResults(context)
else -> err(cmd, "unknown command: $cmd")
}
return result.put("cmd", cmd)
}
// MARK: - Lifecycle
private fun start(context: Context): JSONObject {
MeshForegroundService.start(context)
val mesh = mesh(context)
mesh.startServices()
return ok("start").put("peer_id", mesh.myPeerID)
}
private fun stop(context: Context): JSONObject {
try {
MeshServiceHolder.unifiedMeshService?.stopServices()
} catch (e: Exception) {
Log.w(TAG, "stopServices failed: ${e.message}")
}
MeshForegroundService.stop(context)
return ok("stop")
}
// MARK: - Identity
private fun whoami(context: Context): JSONObject {
val mesh = mesh(context)
return ok("whoami")
.put("peer_id", mesh.myPeerID)
.put("identity_fingerprint", mesh.getIdentityFingerprint())
.put("noise_public_key", mesh.getStaticNoisePublicKey()?.toHex())
.put("nickname", AppStateStore.nickname.value)
}
private fun setNickname(context: Context, name: String): JSONObject {
DataManager(context).saveNickname(name)
AppStateStore.setNickname(name)
mesh(context).sendBroadcastAnnounce()
return ok("set_nickname").put("nickname", name)
}
// MARK: - Discovery / connection
private suspend fun scan(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_SCAN_TIMEOUT_MS)
val minPeers = intent.getIntExtra("min_peers", 1)
val mesh = mesh(context)
val found = withTimeoutOrNull(timeoutMs) {
AppStateStore.peers.first { it.size >= minPeers }
}
val peerIds = found ?: AppStateStore.peers.value
return ok("scan")
.put("reached_min_peers", found != null)
.put("peers", peerInfosJson(mesh, peerIds))
}
private fun peers(context: Context): JSONObject {
val mesh = mesh(context)
return ok("peers").put("peers", peerInfosJson(mesh, AppStateStore.peers.value))
}
/**
* Debug-only state injection for testing peer-list consumers such as notifications.
* A blank or missing comma-separated value restores the empty state.
*/
private fun injectPeers(commaSeparatedPeers: String?): JSONObject {
val peers = commaSeparatedPeers
.orEmpty()
.split(',')
.map(String::trim)
.filter(String::isNotEmpty)
.distinct()
AppStateStore.setPeers(peers)
return ok("inject_peers").put("peers", JSONArray(peers))
}
private suspend fun connect(peerID: String, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_CONNECT_TIMEOUT_MS)
val ble = MeshServiceHolder.meshService ?: return err("connect", "BLE service not running")
val address = ble.getDeviceAddressForPeer(peerID)
?: return err("connect", "no device address known for peer $peerID (scan first)")
val accepted = ble.connectionManager.connectToAddress(address)
if (!accepted) return err("connect", "connectToAddress($address) rejected")
val direct = withTimeoutOrNull(timeoutMs) {
AppStateStore.directPeers.first { it.contains(peerID) }
}
return ok("connect")
.put("peer", peerID)
.put("address", address)
.put("direct", direct != null)
}
// MARK: - Noise
private suspend fun handshake(context: Context, peerID: String, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_HANDSHAKE_TIMEOUT_MS)
val mesh = mesh(context)
val deadline = System.currentTimeMillis() + timeoutMs
if (!mesh.hasEstablishedSession(peerID)) {
mesh.initiateNoiseHandshake(peerID)
}
var lastState: NoiseSession.NoiseSessionState = NoiseSession.NoiseSessionState.Uninitialized
while (System.currentTimeMillis() < deadline) {
lastState = mesh.getSessionState(peerID)
when (lastState) {
is NoiseSession.NoiseSessionState.Established -> {
return ok("handshake")
.put("peer", peerID)
.put("state", lastState.toString())
.put("fingerprint", mesh.getPeerFingerprint(peerID))
}
is NoiseSession.NoiseSessionState.Failed -> {
return err("handshake", "session failed: $lastState").put("peer", peerID)
}
else -> delay(100)
}
}
return err("handshake", "timeout after ${timeoutMs}ms (last state: $lastState)").put("peer", peerID)
}
private fun session(context: Context, peerID: String): JSONObject {
val mesh = mesh(context)
return ok("session")
.put("peer", peerID)
.put("state", mesh.getSessionState(peerID).toString())
.put("established", mesh.hasEstablishedSession(peerID))
.put("fingerprint", mesh.getPeerFingerprint(peerID))
}
// MARK: - Messaging
private fun announce(context: Context): JSONObject {
mesh(context).sendBroadcastAnnounce()
return ok("announce")
}
private fun broadcastMsg(context: Context, content: String, channel: String?): JSONObject {
mesh(context).sendMessage(content, emptyList(), channel)
return ok("broadcast_msg").put("content", content).put("channel", channel)
}
private fun dmSend(context: Context, peerID: String, content: String, msgID: String?): JSONObject {
val mesh = mesh(context)
val nickname = mesh.getPeerNicknames()[peerID] ?: peerID
val id = msgID ?: "testhook-${System.currentTimeMillis()}"
mesh.sendPrivateMessage(content, peerID, nickname, id)
return ok("dm_send").put("peer", peerID).put("msg_id", id)
}
private suspend fun dmRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val fromPeer = intent.getStringExtra("peer")
val contains = intent.getStringExtra("contains")
val startTime = System.currentTimeMillis()
val mesh = mesh(context)
val match = withTimeoutOrNull(timeoutMs) {
AppStateStore.privateMessages.first { conversations ->
conversations.values.flatten().any { msg ->
msg.timestamp.time >= startTime &&
msg.senderPeerID != mesh.myPeerID &&
(fromPeer == null || msg.senderPeerID == fromPeer) &&
(contains == null || msg.content.contains(contains))
}
}
} ?: return err("dm_recv", "timeout after ${timeoutMs}ms")
val msg = match.values.flatten().first { msg ->
msg.timestamp.time >= startTime &&
msg.senderPeerID != mesh.myPeerID &&
(fromPeer == null || msg.senderPeerID == fromPeer) &&
(contains == null || msg.content.contains(contains))
}
return ok("dm_recv")
.put("from", msg.senderPeerID)
.put("sender", msg.sender)
.put("content", msg.content)
.put("msg_id", msg.id)
}
private suspend fun msgRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val contains = intent.getStringExtra("contains")
val channel = intent.getStringExtra("channel")
val startTime = System.currentTimeMillis()
val mesh = mesh(context)
val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg ->
msg.timestamp.time >= startTime &&
msg.senderPeerID != mesh.myPeerID &&
(contains == null || msg.content.contains(contains)) &&
(channel == null || msg.channel == channel)
}
val found = withTimeoutOrNull(timeoutMs) {
if (channel != null) {
AppStateStore.channelMessages.first { m -> m.values.flatten().any(matches) }
.values.flatten().first(matches)
} else {
AppStateStore.publicMessages.first { l -> l.any(matches) }.first(matches)
}
} ?: return err("msg_recv", "timeout after ${timeoutMs}ms")
return ok("msg_recv")
.put("from", found.senderPeerID)
.put("sender", found.sender)
.put("content", found.content)
.put("channel", found.channel)
.put("msg_id", found.id)
}
// MARK: - Favorite and verification state
private fun favoriteSet(context: Context, peerID: String, enabled: Boolean): JSONObject {
val mesh = mesh(context)
val peerInfo = mesh.getPeerInfo(peerID)
?: return err("favorite_set", "peer is not known")
val noisePublicKey = peerInfo.noisePublicKey
?: return err("favorite_set", "peer Noise key is unavailable")
FavoritesPersistenceService.initialize(context)
FavoritesPersistenceService.shared.updateFavoriteStatus(
noisePublicKey = noisePublicKey,
nickname = peerInfo.nickname,
isFavorite = enabled
)
mesh.sendFavoriteNotification(peerID, enabled)
return favoriteStatus(context, peerID)
}
private fun favoriteStatus(context: Context, peerID: String): JSONObject {
val mesh = mesh(context)
FavoritesPersistenceService.initialize(context)
val relationship = FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
?: mesh.getPeerInfo(peerID)?.noisePublicKey?.let {
FavoritesPersistenceService.shared.getFavoriteStatus(it)
}
val isFavorite = relationship?.isFavorite == true
val theyFavoritedUs = relationship?.theyFavoritedUs == true
return ok("favorite_status")
.put("peer", peerID)
.put("is_favorite", isFavorite)
.put("they_favorited_us", theyFavoritedUs)
.put("is_mutual", isFavorite && theyFavoritedUs)
.put(
"star_state",
when {
isFavorite -> "filled"
theyFavoritedUs -> "outlined_orange"
else -> "outlined"
}
)
}
private fun verificationSet(context: Context, peerID: String, enabled: Boolean): JSONObject {
val mesh = mesh(context)
val fingerprint = mesh.getPeerFingerprint(peerID)
?: return err("verification_set", "peer fingerprint is unavailable")
SecureIdentityStateManager(context).setVerifiedFingerprint(fingerprint, enabled)
return verificationStatus(context, peerID)
}
private fun verificationStatus(context: Context, peerID: String): JSONObject {
val fingerprint = mesh(context).getPeerFingerprint(peerID)
val verified = fingerprint != null &&
SecureIdentityStateManager(context).getVerifiedFingerprints().any {
it.equals(fingerprint, ignoreCase = true)
}
return ok("verification_status")
.put("peer", peerID)
.put("fingerprint", fingerprint ?: JSONObject.NULL)
.put("verified", verified)
}
// MARK: - File transfer
private suspend fun fileSend(context: Context, intent: Intent): JSONObject {
val path = intent.requiredString("path")
val peerID = intent.getStringExtra("peer")
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
val mesh = mesh(context)
val file = File(path)
if (!file.isFile) return err("file_send", "file not found: $path")
val content = withContext(Dispatchers.IO) { file.readBytes() }
if (content.size.toLong() > AppConstants.Media.MAX_FILE_SIZE_BYTES) {
return err("file_send", "file too large: ${content.size} > ${AppConstants.Media.MAX_FILE_SIZE_BYTES}")
}
val packet = BitchatFilePacket(
fileName = file.name,
fileSize = content.size.toLong(),
mimeType = intent.getStringExtra("mime") ?: FileUtils.getMimeTypeFromExtension(file.name),
content = content
)
val encoded = packet.encode() ?: return err("file_send", "failed to TLV-encode packet")
val transferId = sha256Hex(encoded)
val recipient = peerID?.let {
PrivateMediaRecipientResolver.resolve(it, mesh)
?: return err("file_send", "no active mesh route for private conversation: $it")
}
return coroutineScope {
// Subscribe on a background dispatcher before sending so synchronous
// failure events are not missed (SharedFlow has replay=0).
val completion = async(Dispatchers.Default) {
TransferProgressManager.events.first { it.transferId == transferId && it.completed }
}
delay(50)
val sendError = dispatchFileSend(
context,
intent,
mesh,
recipient?.meshPeerID,
packet,
transferId
)
if (sendError != null) {
completion.cancel()
return@coroutineScope sendError.put("cmd", "file_send")
}
val event = withTimeoutOrNull(timeoutMs) { completion.await() }
?: return@coroutineScope err("file_send", "timeout waiting for transfer completion ($transferId)")
if (event.failed) {
return@coroutineScope err("file_send", "transfer rejected/failed before send ($transferId)")
.put("transfer_id", transferId)
}
ok("file_send")
.put("transfer_id", transferId)
.put("sent", event.sent)
.put("total", event.total)
.put("bytes", content.size)
.put("peer", recipient?.meshPeerID)
.put("conversation", peerID)
}
}
private suspend fun dispatchFileSend(
context: Context,
intent: Intent,
mesh: MeshService,
peerID: String?,
packet: BitchatFilePacket,
transferId: String
): JSONObject? {
if (peerID == null) {
mesh.sendFileBroadcast(packet)
return null
}
if (!mesh.hasEstablishedSession(peerID)) {
val hs = handshake(context, peerID, intent)
if (hs.optString("status") != "ok") return hs
}
// Peer state (capabilities/identity) can lag session establishment;
// retry transient preparation states before giving up.
val prepDeadline = System.currentTimeMillis() + 30_000
while (true) {
when (val prep = mesh.prepareFilePrivate(peerID, packet, transferId, allowLegacyFallback = false)) {
is PrivateMediaPreparation.Ready -> {
return if (prep.transfer.commit()) null else err("file_send", "private transfer commit failed")
}
PrivateMediaPreparation.AwaitingPeerState,
PrivateMediaPreparation.NeedsHandshake -> {
if (System.currentTimeMillis() >= prepDeadline) {
return err("file_send", "private media preparation stuck at: $prep")
}
if (prep == PrivateMediaPreparation.NeedsHandshake) {
mesh.initiateNoiseHandshake(peerID)
}
delay(500)
}
else -> return err("file_send", "private media preparation: $prep")
}
}
}
private suspend fun fileRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
val nameContains = intent.getStringExtra("name_contains")
val startTime = System.currentTimeMillis()
val dirs = listOf(
File(context.cacheDir, "files/incoming"),
File(context.cacheDir, "images/incoming")
)
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
val candidate = dirs
.flatMap { it.listFiles()?.toList() ?: emptyList() }
.filter { it.lastModified() >= startTime - 5_000 }
.filter { nameContains == null || it.name.contains(nameContains) }
.maxByOrNull { it.lastModified() }
if (candidate != null) {
val size1 = candidate.length()
delay(500)
if (candidate.length() == size1 && size1 > 0) {
return ok("file_recv")
.put("path", candidate.absolutePath)
.put("name", candidate.name)
.put("bytes", size1)
.put("sha256", withContext(Dispatchers.IO) { sha256Hex(candidate.readBytes()) })
}
}
delay(250)
}
return err("file_recv", "timeout after ${timeoutMs}ms")
}
private fun fileCancel(context: Context, transferId: String): JSONObject {
val cancelled = mesh(context).cancelFileTransfer(transferId)
return ok("file_cancel").put("transfer_id", transferId).put("cancelled", cancelled)
}
// MARK: - Live push-to-talk
private suspend fun pttSend(context: Context, intent: Intent): JSONObject {
val requestedPeer = intent.getStringExtra("peer")
val durationMs = intent.getIntExtra("duration_ms", 1_500).toLong().coerceIn(700L, 10_000L)
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
val mesh = mesh(context)
LiveVoicePreferences.setEnabled(context, true)
val recipient = requestedPeer?.let {
PrivateMediaRecipientResolver.resolve(it, mesh)
?: return err("ptt_send", "no active mesh route for private conversation")
}
if (recipient != null && !mesh.hasEstablishedSession(recipient.meshPeerID)) {
val handshake = handshake(context, recipient.meshPeerID, intent)
if (handshake.optString("status") != "ok") return handshake.put("cmd", "ptt_send")
}
val target = LiveVoiceTarget { payload -> mesh.sendVoiceFrame(recipient?.meshPeerID, payload) }
val recorder = LiveVoiceCapture(
File(context.filesDir, "voicenotes/outgoing"),
target,
syntheticPcm = true
)
val pendingFile = recorder.start() ?: return err("ptt_send", "live codec failed to start")
delay(durationMs)
val finalFile = recorder.stop(canceled = false)
?: return err("ptt_send", "capture did not produce a finalized note")
val captureStats = recorder.stats()
if (finalFile != pendingFile || !finalFile.isFile) {
return err("ptt_send", "finalized note is unavailable")
}
val content = withContext(Dispatchers.IO) { finalFile.readBytes() }
val packet = BitchatFilePacket(
fileName = finalFile.name,
fileSize = content.size.toLong(),
mimeType = "audio/mp4",
content = content
)
val encoded = packet.encode() ?: return err("ptt_send", "failed to encode finalized note")
val transferId = sha256Hex(encoded)
return coroutineScope {
val completion = async(Dispatchers.Default) {
TransferProgressManager.events.first { it.transferId == transferId && it.completed }
}
delay(50)
val sendError = dispatchFileSend(
context,
intent,
mesh,
recipient?.meshPeerID,
packet,
transferId
)
if (sendError != null) {
completion.cancel()
return@coroutineScope sendError.put("cmd", "ptt_send")
}
val event = withTimeoutOrNull(timeoutMs) { completion.await() }
?: return@coroutineScope err("ptt_send", "timeout waiting for finalized note transfer")
if (event.failed) return@coroutineScope err("ptt_send", "finalized note transfer failed")
ok("ptt_send")
.put("live", true)
.put("scope", if (recipient == null) "public" else "dm")
.put("duration_ms", durationMs)
.put("burst_id", LiveVoiceManager.burstIDFromVoiceFileName(finalFile.name))
.put("bytes", content.size)
.put("queued_pcm_frames", captureStats.queuedPcmFrames)
.put("encoded_frames", captureStats.encodedFrames)
.put("data_packets", captureStats.dataPackets)
.put("dropped_oversize_frames", captureStats.droppedOversizeFrames)
.put("outbound_packets", captureStats.outboundPackets)
.put("delivered_packets", captureStats.deliveredPackets)
}
}
private suspend fun pttRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_FILE_TIMEOUT_MS)
val fromPeer = intent.getStringExtra("peer")
val expectedScope = if (intent.getStringExtra("scope") == "public") {
LiveVoiceScope.PUBLIC_MESH
} else {
LiveVoiceScope.DIRECT_MESSAGE
}
LiveVoicePreferences.setEnabled(context, true)
var finished: LiveVoiceEvent.Finished? = null
var liveSnapshot: File? = null
val absorbed = withTimeoutOrNull(timeoutMs) {
LiveVoiceManager.getInstance(context).events.first { event ->
val matches = event.scope == expectedScope &&
(fromPeer == null || event.peerID == fromPeer)
if (matches && event is LiveVoiceEvent.Finished) {
finished = event
liveSnapshot = runCatching {
File(context.cacheDir, "testhook/ptt-${event.burstID}.aac").also { snapshot ->
snapshot.parentFile?.mkdirs()
File(event.path).copyTo(snapshot, overwrite = true)
}
}.getOrNull()
}
matches && event is LiveVoiceEvent.Absorbed
} as LiveVoiceEvent.Absorbed
} ?: return err("ptt_recv", "timeout waiting for live burst and finalized note")
val analysis = liveSnapshot?.let { snapshot ->
try {
withContext(Dispatchers.IO) { PttTestAudioAnalyzer.analyze(snapshot) }
} finally {
snapshot.delete()
}
} ?: return err("ptt_recv", "live AAC snapshot was unavailable")
return ok("ptt_recv")
.put("live_observed", finished != null)
.put("scope", if (expectedScope == LiveVoiceScope.PUBLIC_MESH) "public" else "dm")
.put("from", absorbed.peerID)
.put("burst_id", absorbed.burstID)
.put("frames", finished?.frames ?: 0)
.put("data_packets", finished?.dataPackets ?: 0)
.put("bytes", finished?.bytes ?: 0)
.put("expected_packets", finished?.expectedPackets ?: 0)
.put("missing_packets", finished?.missingPackets ?: 0)
.put("decoded_samples", analysis.decodedSamples)
.put("rms", analysis.rms)
.put("silent_block_fraction", analysis.silentBlockFraction)
.put("longest_silent_block_run", analysis.longestSilentBlockRun)
.put("zero_crossings_per_second", analysis.zeroCrossingsPerSecond)
}
// MARK: - Raw packet injection
private fun rawSend(context: Context, intent: Intent): JSONObject {
val payloadHex = intent.requiredString("payload_hex")
val typeStr = intent.requiredString("type")
val peerID = intent.getStringExtra("peer")
val ttl = intent.getIntExtra("ttl", 7)
val type = typeStr.toUIntOrNull(16)?.toUByte()
?: return err("raw_send", "invalid type hex: $typeStr")
val payload = hexToBytes(payloadHex)
?: return err("raw_send", "invalid payload_hex")
val mesh = mesh(context)
val packet = BitchatPacket(
type = type,
ttl = ttl.toUByte(),
senderID = mesh.myPeerID,
payload = payload
)
if (peerID != null) {
TransportBridgeService.sendToPeerFromLocal(peerID, packet)
} else {
TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
}
return ok("raw_send")
.put("type", typeStr)
.put("payload_bytes", payload.size)
.put("peer", peerID)
}
// MARK: - Transport / state
private fun setBle(enabled: Boolean): JSONObject {
val ble = MeshServiceHolder.meshService ?: return err("ble", "BLE service not running")
ble.setBleTransportEnabled(enabled)
return ok("ble").put("enabled", enabled)
}
private fun state(context: Context): JSONObject {
val mesh = mesh(context)
val peersJson = peerInfosJson(mesh, AppStateStore.peers.value)
val sessions = JSONObject()
AppStateStore.peers.value.forEach { peerID ->
sessions.put(peerID, mesh.getSessionState(peerID).toString())
}
return ok("state")
.put("peer_id", mesh.myPeerID)
.put("nickname", AppStateStore.nickname.value)
.put("peers", peersJson)
.put("direct_peers", JSONArray(AppStateStore.directPeers.value.toList()))
.put("sessions", sessions)
.put("device_map", JSONObject(mesh.getDeviceAddressToPeerMapping() as Map<*, *>))
.put("debug_status", mesh.getDebugStatus())
}
private fun clearResults(context: Context): JSONObject {
val dir = File(context.cacheDir, "testhook/results")
val count = dir.listFiles()?.count { it.delete() } ?: 0
return ok("clear_results").put("deleted", count)
}
// MARK: - Helpers
private fun mesh(context: Context): MeshService = MeshServiceHolder.getUnifiedOrCreate(context)
private fun peerInfosJson(mesh: MeshService, peerIds: List<String>): JSONArray {
val nicknames = mesh.getPeerNicknames()
val rssi = mesh.getPeerRSSI()
val arr = JSONArray()
peerIds.forEach { id ->
val info = mesh.getPeerInfo(id)
arr.put(JSONObject()
.put("id", id)
.put("nickname", nicknames[id] ?: info?.nickname)
.put("rssi", rssi[id])
.put("direct", AppStateStore.directPeers.value.contains(id))
.put("connected", info?.isConnected)
.put("last_seen", info?.lastSeen)
.put("session", mesh.getSessionState(id).toString())
.put("fingerprint", mesh.getPeerFingerprint(id)))
}
return arr
}
private fun ok(cmd: String) = JSONObject().put("status", "ok").put("cmd", cmd)
private fun err(cmd: String, message: String) =
JSONObject().put("status", "error").put("cmd", cmd).put("error", message)
private fun Intent.requiredString(name: String): String =
getStringExtra(name) ?: throw IllegalArgumentException("missing required extra: $name")
private fun sha256Hex(data: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(data).toHex()
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private fun hexToBytes(hex: String): ByteArray? {
val clean = hex.replace(" ", "")
if (clean.length % 2 != 0) return null
return try {
ByteArray(clean.length / 2) { i ->
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
} catch (e: Exception) {
null
}
}
}

View File

@ -0,0 +1,65 @@
package com.bitchat.android.testhook
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.json.JSONObject
import java.io.File
/**
* ADB-drivable test hook (debug builds only).
*
* Usage:
* adb shell am broadcast -a com.bitchat.droid.TEST_HOOK \
* --es cmd <command> --es id <cmd-id> [command extras...]
*
* Result is written to cache/testhook/results/<id>.json and logged under tag TestHook:
* adb shell run-as com.bitchat.droid cat cache/testhook/results/<id>.json
*/
class TestHookReceiver : BroadcastReceiver() {
companion object {
const val TAG = "TestHook"
const val ACTION = "com.bitchat.droid.TEST_HOOK"
private const val DEFAULT_OVERALL_TIMEOUT_MS = 180_000L
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION) return
val cmd = intent.getStringExtra("cmd") ?: "ping"
val id = intent.getStringExtra("id") ?: "cmd-${System.currentTimeMillis()}"
val overallTimeout = intent.getLongExtra("overall_timeout_ms", DEFAULT_OVERALL_TIMEOUT_MS)
Log.i(TAG, "CMD id=$id cmd=$cmd")
val pendingResult = goAsync()
Thread {
val result = try {
runBlocking {
withTimeout(overallTimeout) {
TestHookDriver.execute(context.applicationContext, cmd, intent)
}
}
} catch (e: Exception) {
JSONObject()
.put("status", "error")
.put("cmd", cmd)
.put("error", "${e.javaClass.simpleName}: ${e.message}")
}
try {
val dir = File(context.cacheDir, "testhook/results").apply { mkdirs() }
File(dir, "$id.json").writeText(result.toString())
} catch (e: Exception) {
Log.e(TAG, "Failed to write result file for $id: ${e.message}")
}
Log.i(TAG, "RESULT id=$id $result")
}.start()
// Finish immediately: long-running commands continue on the worker thread and
// report via the result file. Holding the broadcast open past the system
// broadcast window would ANR the app.
pendingResult.finish()
}
}

View File

@ -2,6 +2,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Internet permissions for Nostr relay connections -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- Bluetooth permissions -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
@ -12,10 +17,46 @@
<!-- Location permission required for BLE scanning -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Notification permissions -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- WiFi / WiFi Aware permissions (also used for hotspot APK sharing) -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Android 13+ runtime permission for WiFi operations (including Aware and WiFi P2P) -->
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- Android 17+ gates local network access; WiFi Aware peers over link-local IPv6 -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Keep hotspot alive while sharing the APK -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Signature permission for internal UI shutdown broadcasts -->
<uses-permission android:name="com.bitchat.android.permission.FORCE_FINISH" />
<!-- Foreground service and boot permissions for long-running background mesh -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Connected device foreground service type for BLE operations (API 34+) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<!-- Data sync foreground service type (required when declaring dataSync) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Location foreground service type required for BLE scanning in background -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Microphone for voice notes -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Camera for QR verification -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Storage permissions for file sharing -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- Haptic feedback permission -->
<uses-permission android:name="android.permission.VIBRATE" />
@ -25,6 +66,13 @@
<!-- Hardware features -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
<!-- Device support hint for WiFi Aware (optional) -->
<uses-feature android:name="android.hardware.wifi.aware" android:required="false" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<permission
android:name="com.bitchat.android.permission.FORCE_FINISH"
android:protectionLevel="signature" />
<application
android:name=".BitchatApplication"
@ -32,23 +80,92 @@
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.BitchatAndroid"
tools:targetApi="31">
tools:targetApi="33">
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
<!-- FileProvider for sharing temp/cache files with external viewers -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".ui.GeohashPickerActivity"
android:exported="false"
android:theme="@style/Theme.BitchatAndroid"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.BitchatAndroid"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="bitchat" android:host="verify" />
</intent-filter>
</activity>
<!-- Persistent foreground service to run the mesh in background -->
<service
android:name=".service.MeshForegroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync|location"
tools:ignore="DataExtractionRules">
</service>
<receiver
android:name=".service.ConversationNotificationReceiver"
android:enabled="true"
android:exported="false" />
<!-- Auto-start mesh service after boot if enabled -->
<receiver
android:name=".service.BootCompletedReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- Hotspot Activity for offline APK sharing -->
<activity
android:name=".hotspot.HotspotActivity"
android:exported="false"
android:label="Share BitChat"
android:theme="@style/Theme.BitchatAndroid"
android:launchMode="singleTop" />
<!-- Declare the foreground service type for WorkManager's foreground
service so the APK download worker can run as dataSync work -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>

View File

@ -0,0 +1,6 @@
# Design-spec icons
These standalone SVGs were extracted from the supplied 393 px Figma screen exports. The matching
Android vector resources in `res/drawable/ic_spec_*.xml` are the runtime copies used by Compose.
Paths and stroke weights remain faithful to the exports; UI tint and opacity are applied at the
call site so selected and disabled states remain theme-aware.

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="M3 15L8 11.267L13 15V2.4C13 2.029 12.856 1.673 12.601 1.41C12.345 1.147 11.998 1 11.636 1H4.364C4.002 1 3.655 1.147 3.399 1.41C3.144 1.673 3 2.029 3 2.4V15Z" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 15L8 11.267L13 15V2.4C13 2.029 12.856 1.673 12.601 1.41C12.345 1.147 11.998 1 11.636 1H4.364C4.002 1 3.655 1.147 3.399 1.41C3.144 1.673 3 2.029 3 2.4V15Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 362 B

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -370)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M28.5 370.625H18.5C18.0027 370.625 17.5258 370.823 17.1742 371.174C16.8225 371.526 16.625 372.003 16.625 372.5V378.75C16.625 379.247 16.8225 379.724 17.1742 380.076C17.5258 380.427 18.0027 380.625 18.5 380.625H20.375V384.375L24.125 380.625H28.5C28.9973 380.625 29.4742 380.427 29.8258 380.076C30.1775 379.724 30.375 379.247 30.375 378.75V372.5C30.375 372.003 30.1775 371.526 29.8258 371.174C29.4742 370.823 28.9973 370.625 28.5 370.625Z"/>
<path d="M24.125 385.625H27.875L31.625 389.375V385.625H33.5C33.9973 385.625 34.4742 385.427 34.8258 385.076C35.1775 384.724 35.375 384.247 35.375 383.75V377.5C35.375 377.003 35.1775 376.526 34.8258 376.174C34.4742 375.823 33.9973 375.625 33.5 375.625H32.875"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 955 B

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -407)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M26.625 422.875H35.375"/>
<path d="M16.625 407.875L24.125 415.375L16.625 422.875"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -682)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M16.625 692C16.625 692 20.375 685.125 26 685.125C31.625 685.125 35.375 692 35.375 692C35.375 692 31.625 698.875 26 698.875C20.375 698.875 16.625 692 16.625 692Z"/>
<path d="M22.25 692C22.25 689.929 23.9288 688.25 26 688.25M29.75 692C29.75 694.071 28.0712 695.75 26 695.75M17.25 700.75L34.75 683.25"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 555 B

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -278)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M25.9999 297.375C28.2187 297.375 30.0174 293.178 30.0174 288C30.0174 282.822 28.2187 278.625 25.9999 278.625C23.7811 278.625 21.9824 282.822 21.9824 288C21.9824 293.178 23.7811 297.375 25.9999 297.375Z"/>
<path d="M16.625 288H35.375"/>
<path d="M26 297.375C31.1777 297.375 35.375 293.178 35.375 288C35.375 282.822 31.1777 278.625 26 278.625C20.8223 278.625 16.625 282.822 16.625 288C16.625 293.178 20.8223 297.375 26 297.375Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 687 B

View File

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -310)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M32.25 329.375H19.75C18.715 329.375 17.875 328.535 17.875 327.5V321.25C17.875 320.215 18.715 319.375 19.75 319.375H32.25C33.285 319.375 34.125 320.215 34.125 321.25V327.5C34.125 328.535 33.285 329.375 32.25 329.375Z"/>
<path d="M21.625 316.875V315C21.625 312.584 23.5838 310.625 26 310.625C28.4162 310.625 30.375 312.584 30.375 315V316.875"/>
<path d="M26 326.25C27.0355 326.25 27.875 325.411 27.875 324.375C27.875 323.339 27.0355 322.5 26 322.5C24.9645 322.5 24.125 323.339 24.125 324.375C24.125 325.411 24.9645 326.25 26 326.25Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 792 B

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -582)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M29.5236 587.888V594.4C29.5236 596.41 32.5761 596.856 34.1749 594.139C35.5299 591.84 35.1974 588.334 33.5049 586.025C31.0149 582.629 25.2574 581.359 21.0736 584.166C17.2311 586.746 15.8624 591.968 17.9861 596.188C20.0874 600.364 25.0199 602.386 29.4861 600.876"/>
<path d="M25.8485 595.785C27.8698 595.785 29.5085 594.032 29.5085 591.87C29.5085 589.708 27.8698 587.955 25.8485 587.955C23.8271 587.955 22.1885 589.708 22.1885 591.87C22.1885 594.032 23.8271 595.785 25.8485 595.785Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 738 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 15.5H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 12.5V8.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="8" cy="4.5" r="4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 395 B

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path transform="translate(-16 -786)" d="M17.875 805.375H22.875M20.375 786.625V789.125M20.375 789.125L27.875 796.625M16.625 790.375L22.875 796.625M30.375 789.125L35.375 794.125M25.375 789.125L32.875 796.625M35.375 789.125H16.625V796.625H35.375V789.125ZM20.375 805.375V799.125M29.125 805.375H34.125M31.625 786.625V789.125M31.625 805.375V799.125" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 541 B

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -314)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M28.2187 328.116L25.14 327.241C25.0239 327.208 24.9198 327.142 24.8404 327.051C24.761 326.96 24.7096 326.848 24.6925 326.729L24.4338 324.907C25.0869 324.609 25.6407 324.129 26.0291 323.525C26.4176 322.921 26.6244 322.218 26.625 321.5V319.782C26.6402 318.788 26.2704 317.826 25.593 317.098C24.9156 316.37 23.9829 315.932 22.99 315.875C22.488 315.86 21.9879 315.945 21.5196 316.127C21.0513 316.308 20.6242 316.582 20.2637 316.932C19.9032 317.282 19.6166 317.7 19.421 318.163C19.2254 318.625 19.1248 319.123 19.125 319.625V321.5C19.1256 322.218 19.3324 322.921 19.7209 323.525C20.1093 324.129 20.6631 324.609 21.3162 324.907L21.0575 326.724C21.0404 326.843 20.989 326.955 20.9096 327.046C20.8302 327.137 20.7261 327.203 20.61 327.236L17.5313 328.111C17.2702 328.186 17.0406 328.343 16.8771 328.56C16.7136 328.777 16.6251 329.041 16.625 329.312V332.125H29.125V329.317C29.1249 329.046 29.0364 328.782 28.8729 328.565C28.7094 328.348 28.4798 328.191 28.2187 328.116Z"/>
<path d="M31.625 332.125H35.375V328.101C35.375 327.823 35.2819 327.552 35.1104 327.332C34.939 327.113 34.6991 326.956 34.4288 326.889L30.7825 325.977C30.6618 325.947 30.5528 325.882 30.4695 325.789C30.3863 325.697 30.3324 325.582 30.315 325.459L30.0587 323.657C30.7119 323.359 31.2657 322.879 31.6541 322.275C32.0426 321.671 32.2494 320.968 32.25 320.25V318.532C32.2652 317.538 31.8954 316.576 31.218 315.848C30.5406 315.12 29.6079 314.682 28.615 314.625C27.9181 314.603 27.229 314.777 26.625 315.125"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -242)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M27.725 252.271L28.1875 255.125H31.48C32.3075 255.125 33.0375 255.669 33.2763 256.461L34.75 261.375H17.25L18.7237 256.461C18.9612 255.669 19.6912 255.125 20.52 255.125H23.8125L24.275 252.271"/>
<path d="M30.375 247C30.375 244.584 28.4162 242.625 26 242.625C23.5838 242.625 21.625 244.584 21.625 247V248.25C21.625 250.666 23.5838 252.625 26 252.625C28.4162 252.625 30.375 250.666 30.375 248.25V247Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 655 B

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -378)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M25.375 386.125C26.0654 386.125 26.625 385.565 26.625 384.875C26.625 384.185 26.0654 383.625 25.375 383.625C24.6846 383.625 24.125 384.185 24.125 384.875C24.125 385.565 24.6846 386.125 25.375 386.125Z"/>
<path d="M25.375 397.375V386.125M21.625 397.375H29.125M28.9102 388.41C29.8475 387.472 30.3741 386.201 30.3741 384.875C30.3741 383.549 29.8475 382.278 28.9102 381.34M31.5625 391.062C32.3751 390.25 33.0197 389.285 33.4595 388.224C33.8993 387.162 34.1256 386.024 34.1256 384.875C34.1256 383.726 33.8993 382.588 33.4595 381.526C33.0197 380.465 32.3751 379.5 31.5625 378.688M21.8399 388.41C20.9026 387.472 20.376 386.201 20.376 384.875C20.376 383.549 20.9026 382.278 21.8399 381.34M19.1876 391.062C18.375 390.25 17.7304 389.285 17.2907 388.224C16.8509 387.162 16.6245 386.024 16.6245 384.875C16.6245 383.726 16.8509 382.588 17.2907 381.526C17.7304 380.465 18.375 379.5 19.1876 378.688"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path transform="translate(-16 -734)" d="M16.625 749.625H16.88C18.3088 749.625 19.6688 749.014 20.6175 747.946L27.6338 740.053C28.5825 738.985 29.9425 738.374 31.3712 738.374H35.375M31.625 734.625L35.375 738.375L31.625 742.125M26.3477 746.5L27.6339 747.946C28.5827 749.014 29.9427 749.625 31.3714 749.625H35.3752M16.625 738.375H16.88C18.3088 738.375 19.6688 738.986 20.6175 740.054L21.9025 741.5M31.625 753.375L35.375 749.625L31.625 745.875" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 638 B

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path transform="translate(-16 -446)" d="M26 446.925L28.1637 453.836H35.375L29.645 458.143L31.8975 465.075L26 460.79L20.1025 465.075L22.355 458.143L16.625 453.836H23.8363L26 446.925Z" fill="currentColor" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 388 B

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path transform="translate(-16 -446)" d="M26 446.925L28.1637 453.836H35.375L29.645 458.143L31.8975 465.075L26 460.79L20.1025 465.075L22.355 458.143L16.625 453.836H23.8363L26 446.925Z" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="7.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="3 3"/>
<circle cx="5" cy="6" r="1" fill="currentColor"/><circle cx="11" cy="6" r="1" fill="currentColor"/>
<path d="M5.5 10.5H10.5" stroke="currentColor" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 405 B

View File

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path d="M0 5H2.5V12.5H0ZM2.5 2.5H5V15H2.5ZM5 0H7.5V15H5ZM7.5 0H10V17.5H7.5ZM10 0H12.5V17.5H10ZM12.5 0H15V20H12.5ZM15 2.5H17.5V15H15ZM17.5 5H20V12.5H17.5Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 271 B

View File

@ -0,0 +1,8 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-16 -242)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.1387 252.139C22.4278 250.851 24.1757 250.127 25.998 250.127C27.8204 250.127 29.5683 250.851 30.8574 252.139"/>
<path d="M27.25 255.601C27.4831 255.81 27.6603 256.073 27.7656 256.368C27.8708 256.662 27.9008 256.978 27.8527 257.287C27.8046 257.596 27.68 257.888 27.4902 258.137C27.3004 258.385 27.0514 258.582 26.766 258.71C26.4806 258.838 26.1677 258.892 25.856 258.868C25.5442 258.844 25.2433 258.743 24.9809 258.573C24.7184 258.403 24.5026 258.17 24.3531 257.895C24.2036 257.62 24.1252 257.313 24.125 257"/>
<path d="M17.6025 248.604C19.8294 246.378 22.8493 245.127 25.9982 245.127C29.147 245.127 32.1669 246.378 34.3938 248.604"/>
<path d="M34.125 243.875L19.125 258.875"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 942 B

View File

@ -0,0 +1,416 @@
Relay URL,Latitude,Longitude
nostr.bitcoiner.social,40.7608,-111.891
relay.mostr.pub,43.6532,-79.3832
relay.veganostr.com:443,60.1699,24.9384
relay.dreamith.to,43.6532,-79.3832
buzz.cashu.space,50.1109,8.68213
relay.sincensura.org,43.6532,-79.3832
relayrs.notoshi.win:443,43.6532,-79.3832
nostr.bond,50.1109,8.68213
relay.edufeed.org,49.4521,11.0767
relay.bullishbounty.com:443,43.6532,-79.3832
relay.nostr.blockhenge.com,39.0438,-77.4874
relay2.fiatdenier.com,50.1013,8.62643
relay.mwaters.net,50.9871,2.12554
relay2.angor.io,48.1046,11.6002
memlay.v0l.io,53.3498,-6.26031
nostr.wecsats.io,43.6532,-79.3832
vm-1734.lnvps.cloud,53.3498,-6.26031
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
nostr2.girino.org:443,43.6532,-79.3832
nostr.robosats.org,64.1476,-21.9392
relay.atsocy.com,43.6532,-79.3832
relayone.geektank.ai:443,39.0997,-94.5786
relay.olas.app:443,60.1699,24.9384
relay.nostu.be,40.4167,-3.70329
relay.otrta.me,50.1109,8.68213
nostr.spaceshell.xyz,43.6532,-79.3832
rilo.nostria.app,43.6532,-79.3832
relay.agorist.space,52.3734,4.89406
relay.nostrmap.net,60.1699,24.9384
relay.bullishbounty.com,43.6532,-79.3832
nrs-01.darkcloudarcade.com,39.0997,-94.5786
relay.angor.io:443,48.1046,11.6002
nostr.rtvslawenia.com,49.4543,11.0746
relay.wasabiwallet.io,43.6532,-79.3832
cs-relay.nostrdev.com:443,50.4754,12.3683
nostr.4rs.nl,49.0291,8.35696
nexus.libernet.app,43.6532,-79.3832
relay-dev.gulugulu.moe,43.6532,-79.3832
nostr.88mph.life,52.1941,-2.21905
relayone.soundhsa.com:443,39.0997,-94.5786
social.amanah.eblessing.co,48.1046,11.6002
relay.getsafebox.app,43.6532,-79.3832
relay.44billion.net,43.6532,-79.3832
relay.conduit.market,38.7946,-106.535
nostr.infero.net,35.6764,139.65
relay.staging.plebeian.market,51.5072,-0.127586
relay.snort.social,53.3498,-6.26031
purplerelay.com:443,43.6532,-79.3832
nostr.mas-family.eu,60.3478,15.7505
relay.ru.ac.th,13.7607,100.627
relay.openresist.com,43.6532,-79.3832
antiprimal.net,43.6532,-79.3832
relay.illuminodes.com,43.6532,-79.3832
relay.mmwaves.de:443,48.8575,2.35138
nostr.spicyz.io,43.6532,-79.3832
relay.nostrfeed.com,60.1699,24.9384
strfry.apps3.slidestr.net,40.4167,-3.70329
relay.directsponsor.net,42.8864,-78.8784
relay.nostrhub.fr,48.1045,11.6004
nostr.plantroon.com,50.1013,8.62643
nostr-verified.wellorder.net,45.5201,-122.99
relay.bnos.space:443,43.6532,-79.3832
relay.pyramid.li,47.4093,8.46503
public.crostr.com,43.6532,-79.3832
relay.kilombino.com,43.6532,-79.3832
relay.libernet.app:443,43.6532,-79.3832
relay.internationalright-wing.org,-22.4692,-48.9875
nostr.overmind.lol:443,43.6532,-79.3832
relay.ordoplay.com,50.1109,8.68213
mostro-p2p.tech,50.1109,8.68213
offchain.pub:443,39.1585,-94.5728
cs-relay.nostrdev.com,50.4754,12.3683
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
nostr.unkn0wn.world,46.8499,9.53287
relay.shadowbip.com,50.1109,8.68213
relay.lanacoin-eternity.com:443,40.8302,-74.1299
relay.staging.commonshub.brussels,49.4543,11.0746
relay2.angor.io:443,48.1046,11.6002
relay.arx-ccn.com,50.4754,12.3683
relay.underorion.se,50.1109,8.68213
bread.nostrsms.com,40.8218,-74.45
relay.cypherflow.ai,48.8575,2.35138
nostr.islandarea.net:443,35.4669,-97.6473
relay.homeinhk.xyz,35.694,139.754
relay.ditto.pub:443,43.6532,-79.3832
nostr.fullstackcash.net,45.5201,-122.99
bitcoinostr.duckdns.org,38.9504,-0.14007
relay.trotters.cc,43.6532,-79.3832
portal-relay.pareto.space,49.0291,8.35696
relay.openresist.com:443,43.6532,-79.3832
relay.endfiat.money,59.3327,18.0656
nostr.hoppe-relay.it.com,42.8864,-78.8784
nostr.sovereignservices.xyz,43.6532,-79.3832
relay.fundstr.me,42.3601,-71.0589
relay5.bitransfer.org,43.6532,-79.3832
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
testr.nymble.world,40.8054,-74.0241
strfry.shock.network:443,39.0438,-77.4874
chorus.mikedilger.com:444,-36.8906,174.794
relay.minibolt.info,43.6532,-79.3832
nos.lol:443,50.4754,12.3683
relay.liberbitworld.org,43.6532,-79.3832
maxq.descendant.io,43.6532,-79.3832
nostrcity-club.fly.dev,38.7946,-106.535
relay.nostriot.com,41.5695,-83.9786
nostr.n7ekb.net,47.4941,-122.294
relay.erybody.com,41.4513,-81.7021
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
nostrrelay.taylorperron.com,45.5029,-73.5723
syb.lol,34.0549,-118.243
relay.nostr.com,50.1109,8.68213
relay.hivetalk.org,40.8302,-74.1299
relay.degmods.com,50.4754,12.3683
relay.laantungir.net,-19.4692,-42.5315
relay.nearhood.co.uk,51.5134,-0.0890675
relay.tdw.lol,41.8781,-87.6298
ribo.us.nostria.app,43.6532,-79.3832
public.obelisk.ar,43.6532,-79.3832
prl.plus,55.7628,37.5983
21milionidinostr.duckdns.org,45.5192,9.08625
nostr.carroarmato0.be,50.914,3.21378
nostr.pbfs.io:443,50.4754,12.3683
public.crostr.com:443,43.6532,-79.3832
espelho.girino.org,43.6532,-79.3832
nostr.computingcache.com,45.5341,-122.956
relay.chorus.community:443,48.5333,10.7
nostr-relay.nextblockvending.com,47.2343,-119.853
nostr.easycryptosend.it,43.6532,-79.3832
no.str.cr:443,8.96171,-83.5246
nostr.snowbla.de:443,50.4754,12.3683
bridge.tagomago.me,42.3601,-71.0589
relay.endfiat.money:443,59.3327,18.0656
relay.klabo.world,47.674,-122.122
articles.layer3.news:443,37.3387,-121.885
nostr.21crypto.ch,47.5356,8.73209
relay.kaleidoswap.com,50.8476,4.35717
nostr.relay.hedwig.sh,60.1699,24.9384
nostr.yutakobayashi.com,43.6532,-79.3832
strfry.ymir.cloud,43.6532,-79.3832
relay.littlebitstudios.com,43.6532,-79.3832
testnet-relay.samt.st:443,40.8302,-74.1299
relay.islandbitcoin.com,12.8498,77.6545
relay.zone667.com,60.1699,24.9384
relay.lightning.pub:443,39.0438,-77.4874
relay.layer.systems:443,49.0291,8.35695
offchain.pub,39.1585,-94.5728
nos.lol,50.4754,12.3683
cdn.satellite.earth,40.8302,-74.1299
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
relay.wisp.talk:443,49.4543,11.0746
nostr.hekster.org:443,37.3986,-121.964
nostr.mom:443,50.4754,12.3683
relay.mccormick.cx:443,52.3563,4.95714
dev.relay.stream,43.6532,-79.3832
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.vrtmrz.net:443,43.6532,-79.3832
relayone.soundhsa.com,39.0997,-94.5786
relay.agorist.space:443,52.3734,4.89406
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
nostr.dlcdevkit.com:443,40.0992,-83.1141
node.kommonzenze.de,49.4521,11.0767
relay.beamhop.com,43.6532,-79.3832
nostr.2b9t.xyz:443,34.0549,-118.243
relay.chatbett.de,40.7128,-74.006
relay.tdw.gg,34.0549,-118.243
budabit.nostr1.com,40.7057,-74.0136
nostr.novacisko.cz,52.2026,20.9397
relay.samt.st,40.8302,-74.1299
bruh.samt.st,43.6532,-79.3832
nostr.wild-vibes.ts.net,48.8566,2.35222
relay.opossumwire.org,34.0549,-118.243
relay.angor.io,48.1046,11.6002
relay.nostrmap.net:443,60.1699,24.9384
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
chat-relay.zap-work.com,43.6532,-79.3832
relay.earthly.city,34.1749,-118.54
relay.mappingbitcoin.com,43.6532,-79.3832
relay.satmaxt.xyz,43.6532,-79.3832
relay.primal.net,43.6532,-79.3832
relay.bornheimer.app,50.1109,8.68213
nostr.tagomago.me,42.3601,-71.0589
relay.orly.dev,32.7767,-96.797
no.str.cr,8.96171,-83.5246
auth.nostr1.com,40.7057,-74.0136
relay.opmaat.org,60.1699,24.9384
temp.iris.to,43.6532,-79.3832
conduitl2.fly.dev,38.7946,-106.535
yabu.me,35.6092,139.73
nostrride.io,37.3986,-121.964
nostr.plantroon.com:443,50.1013,8.62643
relay.wavlake.com,41.2619,-95.8608
nostrbtc.com,43.6532,-79.3832
relay.mitchelltribe.com:443,39.0438,-77.4874
relay.manneken.brussels,49.4543,11.0746
relay.lanacoin-eternity.com,40.8302,-74.1299
bendernostur.duckdns.org:8443,50.1109,8.68213
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
nostr.nodesmap.com,59.3327,18.0656
nostr-01.yakihonne.com,1.32123,103.695
bunk-test.feeds.relay.tools,38.6327,-90.1961
relay.btcforplebs.com,43.6532,-79.3832
relay.beginningend.com,35.2227,-97.4786
nostr.iskarion.ddns.net,43.3076,-2.95421
nostr-relay.cbrx.io,43.6532,-79.3832
relay.nostrian-conquest.com,41.223,-111.974
relay.cosmicbolt.net:443,37.3986,-121.964
nostr.oxtr.dev:443,50.4754,12.3683
nostr-relay.acloud.kdns.fr,43.6532,-79.3832
relay.mccormick.cx,52.3563,4.95714
relay.fiatdenier.com,43.7787,-79.5393
nostr.bitcoiner.social:443,40.7608,-111.891
relay.gulugulu.moe:443,43.6532,-79.3832
relay.piazza.today,48.122,11.589
nostr.ac,38.958,-77.3592
strfry.bonsai.com,39.0438,-77.4874
dev-relay.nostreon.com,60.1699,24.9384
relay.olas.app,60.1699,24.9384
relay.loveisbitcoin.com,43.6532,-79.3832
strfry.bonsai.com:443,39.0438,-77.4874
nostr.tac.lol,47.4748,-122.273
rilo.nostria.app:443,43.6532,-79.3832
nostr.azzamo.net,52.2633,21.0283
nostr.wecsats.io:443,43.6532,-79.3832
relay.wellorder.net,45.5201,-122.99
relay.libernet.app,43.6532,-79.3832
nostr.thalheim.io,60.1699,24.9384
relayrs.notoshi.win,43.6532,-79.3832
ribo.nostria.app:443,43.6532,-79.3832
relay.gulugulu.moe,43.6532,-79.3832
relay.qstr.app,50.1109,8.68213
myvoiceourstory.org,37.3598,-121.981
nostr-relay.zimage.com,34.282,-118.439
treuzkas.branruz.com,48.8575,2.35138
top.testrelay.top,43.6532,-79.3832
relay.cyberguy.fyi,52.6907,4.8181
articles.layer3.news,37.3387,-121.885
nostr.sathoarder.com,48.5734,7.75211
nostr.carroarmato0.be:443,50.914,3.21378
nostr-02.uid.ovh,50.9871,2.12554
ribo.us.nostria.app:443,43.6532,-79.3832
relay.ditto.pub,43.6532,-79.3832
nostr.chaima.info:443,50.1109,8.68213
relay.bnos.space,43.6532,-79.3832
relay.solife.me,43.6532,-79.3832
relay.wavlake.com:443,41.2619,-95.8608
bucket.coracle.social,37.7775,-122.397
nostr.christiansass.de,52.5244,13.4105
relay.dyne.org,49.0291,8.35705
testnet-relay.samt.st,40.8302,-74.1299
nostream.seitendan.com,34.7062,135.493
basspistol.org,49.0291,8.35696
purplerelay.com,43.6532,-79.3832
relay.wisp.talk,49.4543,11.0746
relay.getsafebox.app:443,43.6532,-79.3832
freelay.sovbit.host,60.1699,24.9384
relay.sigit.io:443,50.4754,12.3683
buzz.ac2n-share.kozow.com,45.764,4.83566
relay.mostro.network,40.8302,-74.1299
nostrcheck.me,43.6532,-79.3832
nostr.rtvslawenia.com:443,49.4543,11.0746
insta-relay.apps3.slidestr.net,40.4167,-3.70329
nostr.vulpem.com,49.4543,11.0746
relay-na1.metanomalist.com,43.6532,-79.3832
chorus.bonsai.com,39.0438,-77.4874
ec2.f7z.io,60.1699,24.9384
relay-dev.gulugulu.moe:443,43.6532,-79.3832
nostr.twinkle.lol,51.902,7.6657
relay.lanavault.space,60.1699,24.9384
nostr.pbfs.io,50.4754,12.3683
bitchat.nostr1.com,40.7057,-74.0136
nostr.highway15.net,28.0445,-82.6699
nostr.tabordalab.com,60.1699,24.9384
relay.ohstr.com,43.6532,-79.3832
schnorr.me,43.6532,-79.3832
relay.s-w.art,43.6532,-79.3832
relay.nmail.li,50.9871,2.12554
bcast.girino.org,43.6532,-79.3832
relay.nostrops.com,32.71,-96.6745
dm-test-strfry-generic.samt.st,43.6532,-79.3832
relay.keykeeper.world,40.7824,-74.0711
relay.satsmarkt.club,52.6907,4.8181
adre.su,59.9311,30.3609
relay.edufeed.org:443,49.4521,11.0767
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
relay.lightning.pub,39.0438,-77.4874
porchlight.social,43.6532,-79.3832
nostr.snowbla.de,50.4754,12.3683
relay.trotters.cc:443,43.6532,-79.3832
nostr-relay.xbytez.io,50.6924,3.20113
relay.nostr.net,43.6532,-79.3832
nostrcity-club.fly.dev:443,38.7946,-106.535
nostr.mikoshi.de,50.1109,8.68213
nostr.mom,50.4754,12.3683
schnorr.me:443,43.6532,-79.3832
chat-relay.zap-work.com:443,43.6532,-79.3832
relay.paulstephenborile.com:443,49.4543,11.0746
relay.fckstate.net,59.3293,18.0686
offchain.bostr.online,43.6532,-79.3832
nostr.oxtr.dev,50.4754,12.3683
testnet.samt.st,43.6532,-79.3832
nostr.planix.org,43.6532,-79.3832
relay.getvia.xyz,60.1699,24.9384
relay.lacrypta.ar,43.6532,-79.3832
nostr.janx.com,43.6532,-79.3832
relay.yoinekodo.jp,43.6532,-79.3832
nostrelay.circum.space,52.2245,8.826
staging.yabu.me,35.6092,139.73
relay.staging.plebeian.market:443,51.5072,-0.127586
reraw.pbla2fish.cc,43.6532,-79.3832
relay.satsapp.me,50.4754,12.3683
x.kojira.io,43.6532,-79.3832
relay.layer.systems,49.0291,8.35695
relay01.lnfi.network,35.6764,139.65
nostr.whitenode45.ddns.net,40.55,-74.4758
relay-rpi.edufeed.org,49.4521,11.0767
nostr-dev.wellorder.net,45.5201,-122.99
nostr.overmind.lol,43.6532,-79.3832
relay.aarpia.com,37.3986,-121.964
relayone.geektank.ai,39.0997,-94.5786
nostr.purpura.cloud,43.6532,-79.3832
nostr.data.haus:443,50.4754,12.3683
nexus.libernet.app:443,43.6532,-79.3832
nostr.thebiglake.org,32.71,-96.6745
relay.bowlafterbowl.com,32.9483,-96.7299
relay.mitchelltribe.com,39.0438,-77.4874
nostr-relay.xbytez.io:443,50.6924,3.20113
bitcoiner.social,40.7608,-111.891
relay.guggero.org,46.5971,9.59652
relay.mrmave.work,43.6532,-79.3832
relay.flashapp.me,43.6548,-79.3885
relay.mmwaves.de,48.8575,2.35138
nostr.red5d.dev,43.6532,-79.3832
relay.plebeian.market,50.1109,8.68213
nostr.girino.org,43.6532,-79.3832
relay02.lnfi.network,35.6764,139.65
relay.vrtmrz.net,43.6532,-79.3832
ynostr.yael.at,60.1699,24.9384
relay.cosmicbolt.net,37.3986,-121.964
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
nostrelay.circum.space:443,52.2245,8.826
nostr.tac.lol:443,47.4748,-122.273
relay-arg.zombi.cloudrodion.com,1.35208,103.82
relay.agora.social,50.7383,15.0648
vault.iris.to:443,43.6532,-79.3832
relay.nexterz.com,43.6532,-79.3832
relay2.veganostr.com,60.1699,24.9384
strfry.shock.network,39.0438,-77.4874
relay.notoshi.win,13.3396,100.93
vault.iris.to,43.6532,-79.3832
relay.novospes.com,43.6532,-79.3832
nip85.nosfabrica.com,39.0997,-94.5786
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.stakey.net,52.3676,4.90414
syb.lol:443,34.0549,-118.243
nostr-01.uid.ovh,50.9871,2.12554
nostr-02.yakihonne.com,1.32123,103.695
relay.favillakey.io,-27.4705,153.026
relay.plebeian.market:443,50.1109,8.68213
r.iqbqioza.com,43.6532,-79.3832
relay.ohstr.com:443,43.6532,-79.3832
relay.paulstephenborile.com,49.4543,11.0746
nostr.2b9t.xyz,34.0549,-118.243
slick.mjex.me,39.0418,-77.4744
nostr.hekster.org,37.3986,-121.964
nostr.stakey.net:443,52.3676,4.90414
relay.sharegap.net,43.6532,-79.3832
relay.veganostr.com,60.1699,24.9384
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
nostr.data.haus,50.4754,12.3683
x.kojira.io:443,43.6532,-79.3832
relay.nostx.io,43.6532,-79.3832
wheat.happytavern.co,43.6532,-79.3832
nostr.myshosholoza.co.za:443,52.3676,4.90414
inbox.scuba323.com,40.8218,-74.45
nostr.chaima.info,50.1109,8.68213
relay.nostriches.club,43.6532,-79.3832
fanfares.nostr1.com,40.7057,-74.0136
relay.bitmacro.cloud,43.6532,-79.3832
relay.mostr.pub:443,43.6532,-79.3832
nostr.spicyz.io:443,43.6532,-79.3832
relay.agentry.com,42.8864,-78.8784
nostr.thalheim.io:443,60.1699,24.9384
tribune-panel-growing-noon.trycloudflare.com,43.6532,-79.3832
relay.mypathtofire.de,42.8864,-78.8784
nostr-relay.corb.net:443,39.6478,-104.988
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
nostr.debate.report,50.1109,8.68213
relay.nostrcheck.me,43.6532,-79.3832
relay.scuba323.com,40.8218,-74.45
relay.decentralia.fr,48.122,11.589
fanfares.nostr1.com:443,40.7057,-74.0136
relay.sigit.io,50.4754,12.3683
ribo.nostria.app,43.6532,-79.3832
nostr.davenov.com,50.1109,8.68213
nostr.azzamo.net:443,52.2633,21.0283
ribo.eu.nostria.app,43.6532,-79.3832
nostr-01.yakihonne.com:443,1.32123,103.695
desktop-r082364.tail4b3c94.ts.net,48.8566,2.35222
0x-nostr-relay.fly.dev,38.7946,-106.535
soloco.nl,43.6532,-79.3832
relay.chorus.community,48.5333,10.7
nostr.sathoarder.com:443,48.5734,7.75211
nostr.myshosholoza.co.za,52.3676,4.90414
cache.trustr.ing,43.6548,-79.3885
nostr.islandarea.net,35.4669,-97.6473
nostr-relay.corb.net,39.6478,-104.988
relay-rpi.edufeed.org:443,49.4521,11.0767
rele.speyhard.fi,50.1109,8.68213
relay.pocketnostr.com,40.8054,-74.0241
relay.nuts.cash,52.3676,4.90414
spamspamspamspam.rest,43.6532,-79.3832
nostr.dlcdevkit.com,40.0992,-83.1141
relay.satlantis.io,39.0438,-77.4874
nostr.na.social,43.6532,-79.3832
relay.nostreon.com,60.1699,24.9384
1 Relay URL Latitude Longitude
2 nostr.bitcoiner.social 40.7608 -111.891
3 relay.mostr.pub 43.6532 -79.3832
4 relay.veganostr.com:443 60.1699 24.9384
5 relay.dreamith.to 43.6532 -79.3832
6 buzz.cashu.space 50.1109 8.68213
7 relay.sincensura.org 43.6532 -79.3832
8 relayrs.notoshi.win:443 43.6532 -79.3832
9 nostr.bond 50.1109 8.68213
10 relay.edufeed.org 49.4521 11.0767
11 relay.bullishbounty.com:443 43.6532 -79.3832
12 relay.nostr.blockhenge.com 39.0438 -77.4874
13 relay2.fiatdenier.com 50.1013 8.62643
14 relay.mwaters.net 50.9871 2.12554
15 relay2.angor.io 48.1046 11.6002
16 memlay.v0l.io 53.3498 -6.26031
17 nostr.wecsats.io 43.6532 -79.3832
18 vm-1734.lnvps.cloud 53.3498 -6.26031
19 dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
20 nostr2.girino.org:443 43.6532 -79.3832
21 nostr.robosats.org 64.1476 -21.9392
22 relay.atsocy.com 43.6532 -79.3832
23 relayone.geektank.ai:443 39.0997 -94.5786
24 relay.olas.app:443 60.1699 24.9384
25 relay.nostu.be 40.4167 -3.70329
26 relay.otrta.me 50.1109 8.68213
27 nostr.spaceshell.xyz 43.6532 -79.3832
28 rilo.nostria.app 43.6532 -79.3832
29 relay.agorist.space 52.3734 4.89406
30 relay.nostrmap.net 60.1699 24.9384
31 relay.bullishbounty.com 43.6532 -79.3832
32 nrs-01.darkcloudarcade.com 39.0997 -94.5786
33 relay.angor.io:443 48.1046 11.6002
34 nostr.rtvslawenia.com 49.4543 11.0746
35 relay.wasabiwallet.io 43.6532 -79.3832
36 cs-relay.nostrdev.com:443 50.4754 12.3683
37 nostr.4rs.nl 49.0291 8.35696
38 nexus.libernet.app 43.6532 -79.3832
39 relay-dev.gulugulu.moe 43.6532 -79.3832
40 nostr.88mph.life 52.1941 -2.21905
41 relayone.soundhsa.com:443 39.0997 -94.5786
42 social.amanah.eblessing.co 48.1046 11.6002
43 relay.getsafebox.app 43.6532 -79.3832
44 relay.44billion.net 43.6532 -79.3832
45 relay.conduit.market 38.7946 -106.535
46 nostr.infero.net 35.6764 139.65
47 relay.staging.plebeian.market 51.5072 -0.127586
48 relay.snort.social 53.3498 -6.26031
49 purplerelay.com:443 43.6532 -79.3832
50 nostr.mas-family.eu 60.3478 15.7505
51 relay.ru.ac.th 13.7607 100.627
52 relay.openresist.com 43.6532 -79.3832
53 antiprimal.net 43.6532 -79.3832
54 relay.illuminodes.com 43.6532 -79.3832
55 relay.mmwaves.de:443 48.8575 2.35138
56 nostr.spicyz.io 43.6532 -79.3832
57 relay.nostrfeed.com 60.1699 24.9384
58 strfry.apps3.slidestr.net 40.4167 -3.70329
59 relay.directsponsor.net 42.8864 -78.8784
60 relay.nostrhub.fr 48.1045 11.6004
61 nostr.plantroon.com 50.1013 8.62643
62 nostr-verified.wellorder.net 45.5201 -122.99
63 relay.bnos.space:443 43.6532 -79.3832
64 relay.pyramid.li 47.4093 8.46503
65 public.crostr.com 43.6532 -79.3832
66 relay.kilombino.com 43.6532 -79.3832
67 relay.libernet.app:443 43.6532 -79.3832
68 relay.internationalright-wing.org -22.4692 -48.9875
69 nostr.overmind.lol:443 43.6532 -79.3832
70 relay.ordoplay.com 50.1109 8.68213
71 mostro-p2p.tech 50.1109 8.68213
72 offchain.pub:443 39.1585 -94.5728
73 cs-relay.nostrdev.com 50.4754 12.3683
74 relay-can.zombi.cloudrodion.com 43.6532 -79.3832
75 nostr.unkn0wn.world 46.8499 9.53287
76 relay.shadowbip.com 50.1109 8.68213
77 relay.lanacoin-eternity.com:443 40.8302 -74.1299
78 relay.staging.commonshub.brussels 49.4543 11.0746
79 relay2.angor.io:443 48.1046 11.6002
80 relay.arx-ccn.com 50.4754 12.3683
81 relay.underorion.se 50.1109 8.68213
82 bread.nostrsms.com 40.8218 -74.45
83 relay.cypherflow.ai 48.8575 2.35138
84 nostr.islandarea.net:443 35.4669 -97.6473
85 relay.homeinhk.xyz 35.694 139.754
86 relay.ditto.pub:443 43.6532 -79.3832
87 nostr.fullstackcash.net 45.5201 -122.99
88 bitcoinostr.duckdns.org 38.9504 -0.14007
89 relay.trotters.cc 43.6532 -79.3832
90 portal-relay.pareto.space 49.0291 8.35696
91 relay.openresist.com:443 43.6532 -79.3832
92 relay.endfiat.money 59.3327 18.0656
93 nostr.hoppe-relay.it.com 42.8864 -78.8784
94 nostr.sovereignservices.xyz 43.6532 -79.3832
95 relay.fundstr.me 42.3601 -71.0589
96 relay5.bitransfer.org 43.6532 -79.3832
97 nrs-01.darkcloudarcade.com:443 39.0997 -94.5786
98 testr.nymble.world 40.8054 -74.0241
99 strfry.shock.network:443 39.0438 -77.4874
100 chorus.mikedilger.com:444 -36.8906 174.794
101 relay.minibolt.info 43.6532 -79.3832
102 nos.lol:443 50.4754 12.3683
103 relay.liberbitworld.org 43.6532 -79.3832
104 maxq.descendant.io 43.6532 -79.3832
105 nostrcity-club.fly.dev 38.7946 -106.535
106 relay.nostriot.com 41.5695 -83.9786
107 nostr.n7ekb.net 47.4941 -122.294
108 relay.erybody.com 41.4513 -81.7021
109 nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
110 nostrrelay.taylorperron.com 45.5029 -73.5723
111 syb.lol 34.0549 -118.243
112 relay.nostr.com 50.1109 8.68213
113 relay.hivetalk.org 40.8302 -74.1299
114 relay.degmods.com 50.4754 12.3683
115 relay.laantungir.net -19.4692 -42.5315
116 relay.nearhood.co.uk 51.5134 -0.0890675
117 relay.tdw.lol 41.8781 -87.6298
118 ribo.us.nostria.app 43.6532 -79.3832
119 public.obelisk.ar 43.6532 -79.3832
120 prl.plus 55.7628 37.5983
121 21milionidinostr.duckdns.org 45.5192 9.08625
122 nostr.carroarmato0.be 50.914 3.21378
123 nostr.pbfs.io:443 50.4754 12.3683
124 public.crostr.com:443 43.6532 -79.3832
125 espelho.girino.org 43.6532 -79.3832
126 nostr.computingcache.com 45.5341 -122.956
127 relay.chorus.community:443 48.5333 10.7
128 nostr-relay.nextblockvending.com 47.2343 -119.853
129 nostr.easycryptosend.it 43.6532 -79.3832
130 no.str.cr:443 8.96171 -83.5246
131 nostr.snowbla.de:443 50.4754 12.3683
132 bridge.tagomago.me 42.3601 -71.0589
133 relay.endfiat.money:443 59.3327 18.0656
134 relay.klabo.world 47.674 -122.122
135 articles.layer3.news:443 37.3387 -121.885
136 nostr.21crypto.ch 47.5356 8.73209
137 relay.kaleidoswap.com 50.8476 4.35717
138 nostr.relay.hedwig.sh 60.1699 24.9384
139 nostr.yutakobayashi.com 43.6532 -79.3832
140 strfry.ymir.cloud 43.6532 -79.3832
141 relay.littlebitstudios.com 43.6532 -79.3832
142 testnet-relay.samt.st:443 40.8302 -74.1299
143 relay.islandbitcoin.com 12.8498 77.6545
144 relay.zone667.com 60.1699 24.9384
145 relay.lightning.pub:443 39.0438 -77.4874
146 relay.layer.systems:443 49.0291 8.35695
147 offchain.pub 39.1585 -94.5728
148 nos.lol 50.4754 12.3683
149 cdn.satellite.earth 40.8302 -74.1299
150 relay-us.zombi.cloudrodion.com 40.7862 -74.0743
151 relay.wisp.talk:443 49.4543 11.0746
152 nostr.hekster.org:443 37.3986 -121.964
153 nostr.mom:443 50.4754 12.3683
154 relay.mccormick.cx:443 52.3563 4.95714
155 dev.relay.stream 43.6532 -79.3832
156 dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
157 relay.vrtmrz.net:443 43.6532 -79.3832
158 relayone.soundhsa.com 39.0997 -94.5786
159 relay.agorist.space:443 52.3734 4.89406
160 relay-fra.zombi.cloudrodion.com 48.8566 2.35222
161 nostr.dlcdevkit.com:443 40.0992 -83.1141
162 node.kommonzenze.de 49.4521 11.0767
163 relay.beamhop.com 43.6532 -79.3832
164 nostr.2b9t.xyz:443 34.0549 -118.243
165 relay.chatbett.de 40.7128 -74.006
166 relay.tdw.gg 34.0549 -118.243
167 budabit.nostr1.com 40.7057 -74.0136
168 nostr.novacisko.cz 52.2026 20.9397
169 relay.samt.st 40.8302 -74.1299
170 bruh.samt.st 43.6532 -79.3832
171 nostr.wild-vibes.ts.net 48.8566 2.35222
172 relay.opossumwire.org 34.0549 -118.243
173 relay.angor.io 48.1046 11.6002
174 relay.nostrmap.net:443 60.1699 24.9384
175 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
176 chat-relay.zap-work.com 43.6532 -79.3832
177 relay.earthly.city 34.1749 -118.54
178 relay.mappingbitcoin.com 43.6532 -79.3832
179 relay.satmaxt.xyz 43.6532 -79.3832
180 relay.primal.net 43.6532 -79.3832
181 relay.bornheimer.app 50.1109 8.68213
182 nostr.tagomago.me 42.3601 -71.0589
183 relay.orly.dev 32.7767 -96.797
184 no.str.cr 8.96171 -83.5246
185 auth.nostr1.com 40.7057 -74.0136
186 relay.opmaat.org 60.1699 24.9384
187 temp.iris.to 43.6532 -79.3832
188 conduitl2.fly.dev 38.7946 -106.535
189 yabu.me 35.6092 139.73
190 nostrride.io 37.3986 -121.964
191 nostr.plantroon.com:443 50.1013 8.62643
192 relay.wavlake.com 41.2619 -95.8608
193 nostrbtc.com 43.6532 -79.3832
194 relay.mitchelltribe.com:443 39.0438 -77.4874
195 relay.manneken.brussels 49.4543 11.0746
196 relay.lanacoin-eternity.com 40.8302 -74.1299
197 bendernostur.duckdns.org:8443 50.1109 8.68213
198 nostr-kyomu-haskell.onrender.com 37.7775 -122.397
199 nostr.nodesmap.com 59.3327 18.0656
200 nostr-01.yakihonne.com 1.32123 103.695
201 bunk-test.feeds.relay.tools 38.6327 -90.1961
202 relay.btcforplebs.com 43.6532 -79.3832
203 relay.beginningend.com 35.2227 -97.4786
204 nostr.iskarion.ddns.net 43.3076 -2.95421
205 nostr-relay.cbrx.io 43.6532 -79.3832
206 relay.nostrian-conquest.com 41.223 -111.974
207 relay.cosmicbolt.net:443 37.3986 -121.964
208 nostr.oxtr.dev:443 50.4754 12.3683
209 nostr-relay.acloud.kdns.fr 43.6532 -79.3832
210 relay.mccormick.cx 52.3563 4.95714
211 relay.fiatdenier.com 43.7787 -79.5393
212 nostr.bitcoiner.social:443 40.7608 -111.891
213 relay.gulugulu.moe:443 43.6532 -79.3832
214 relay.piazza.today 48.122 11.589
215 nostr.ac 38.958 -77.3592
216 strfry.bonsai.com 39.0438 -77.4874
217 dev-relay.nostreon.com 60.1699 24.9384
218 relay.olas.app 60.1699 24.9384
219 relay.loveisbitcoin.com 43.6532 -79.3832
220 strfry.bonsai.com:443 39.0438 -77.4874
221 nostr.tac.lol 47.4748 -122.273
222 rilo.nostria.app:443 43.6532 -79.3832
223 nostr.azzamo.net 52.2633 21.0283
224 nostr.wecsats.io:443 43.6532 -79.3832
225 relay.wellorder.net 45.5201 -122.99
226 relay.libernet.app 43.6532 -79.3832
227 nostr.thalheim.io 60.1699 24.9384
228 relayrs.notoshi.win 43.6532 -79.3832
229 ribo.nostria.app:443 43.6532 -79.3832
230 relay.gulugulu.moe 43.6532 -79.3832
231 relay.qstr.app 50.1109 8.68213
232 myvoiceourstory.org 37.3598 -121.981
233 nostr-relay.zimage.com 34.282 -118.439
234 treuzkas.branruz.com 48.8575 2.35138
235 top.testrelay.top 43.6532 -79.3832
236 relay.cyberguy.fyi 52.6907 4.8181
237 articles.layer3.news 37.3387 -121.885
238 nostr.sathoarder.com 48.5734 7.75211
239 nostr.carroarmato0.be:443 50.914 3.21378
240 nostr-02.uid.ovh 50.9871 2.12554
241 ribo.us.nostria.app:443 43.6532 -79.3832
242 relay.ditto.pub 43.6532 -79.3832
243 nostr.chaima.info:443 50.1109 8.68213
244 relay.bnos.space 43.6532 -79.3832
245 relay.solife.me 43.6532 -79.3832
246 relay.wavlake.com:443 41.2619 -95.8608
247 bucket.coracle.social 37.7775 -122.397
248 nostr.christiansass.de 52.5244 13.4105
249 relay.dyne.org 49.0291 8.35705
250 testnet-relay.samt.st 40.8302 -74.1299
251 nostream.seitendan.com 34.7062 135.493
252 basspistol.org 49.0291 8.35696
253 purplerelay.com 43.6532 -79.3832
254 relay.wisp.talk 49.4543 11.0746
255 relay.getsafebox.app:443 43.6532 -79.3832
256 freelay.sovbit.host 60.1699 24.9384
257 relay.sigit.io:443 50.4754 12.3683
258 buzz.ac2n-share.kozow.com 45.764 4.83566
259 relay.mostro.network 40.8302 -74.1299
260 nostrcheck.me 43.6532 -79.3832
261 nostr.rtvslawenia.com:443 49.4543 11.0746
262 insta-relay.apps3.slidestr.net 40.4167 -3.70329
263 nostr.vulpem.com 49.4543 11.0746
264 relay-na1.metanomalist.com 43.6532 -79.3832
265 chorus.bonsai.com 39.0438 -77.4874
266 ec2.f7z.io 60.1699 24.9384
267 relay-dev.gulugulu.moe:443 43.6532 -79.3832
268 nostr.twinkle.lol 51.902 7.6657
269 relay.lanavault.space 60.1699 24.9384
270 nostr.pbfs.io 50.4754 12.3683
271 bitchat.nostr1.com 40.7057 -74.0136
272 nostr.highway15.net 28.0445 -82.6699
273 nostr.tabordalab.com 60.1699 24.9384
274 relay.ohstr.com 43.6532 -79.3832
275 schnorr.me 43.6532 -79.3832
276 relay.s-w.art 43.6532 -79.3832
277 relay.nmail.li 50.9871 2.12554
278 bcast.girino.org 43.6532 -79.3832
279 relay.nostrops.com 32.71 -96.6745
280 dm-test-strfry-generic.samt.st 43.6532 -79.3832
281 relay.keykeeper.world 40.7824 -74.0711
282 relay.satsmarkt.club 52.6907 4.8181
283 adre.su 59.9311 30.3609
284 relay.edufeed.org:443 49.4521 11.0767
285 infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
286 relay.lightning.pub 39.0438 -77.4874
287 porchlight.social 43.6532 -79.3832
288 nostr.snowbla.de 50.4754 12.3683
289 relay.trotters.cc:443 43.6532 -79.3832
290 nostr-relay.xbytez.io 50.6924 3.20113
291 relay.nostr.net 43.6532 -79.3832
292 nostrcity-club.fly.dev:443 38.7946 -106.535
293 nostr.mikoshi.de 50.1109 8.68213
294 nostr.mom 50.4754 12.3683
295 schnorr.me:443 43.6532 -79.3832
296 chat-relay.zap-work.com:443 43.6532 -79.3832
297 relay.paulstephenborile.com:443 49.4543 11.0746
298 relay.fckstate.net 59.3293 18.0686
299 offchain.bostr.online 43.6532 -79.3832
300 nostr.oxtr.dev 50.4754 12.3683
301 testnet.samt.st 43.6532 -79.3832
302 nostr.planix.org 43.6532 -79.3832
303 relay.getvia.xyz 60.1699 24.9384
304 relay.lacrypta.ar 43.6532 -79.3832
305 nostr.janx.com 43.6532 -79.3832
306 relay.yoinekodo.jp 43.6532 -79.3832
307 nostrelay.circum.space 52.2245 8.826
308 staging.yabu.me 35.6092 139.73
309 relay.staging.plebeian.market:443 51.5072 -0.127586
310 reraw.pbla2fish.cc 43.6532 -79.3832
311 relay.satsapp.me 50.4754 12.3683
312 x.kojira.io 43.6532 -79.3832
313 relay.layer.systems 49.0291 8.35695
314 relay01.lnfi.network 35.6764 139.65
315 nostr.whitenode45.ddns.net 40.55 -74.4758
316 relay-rpi.edufeed.org 49.4521 11.0767
317 nostr-dev.wellorder.net 45.5201 -122.99
318 nostr.overmind.lol 43.6532 -79.3832
319 relay.aarpia.com 37.3986 -121.964
320 relayone.geektank.ai 39.0997 -94.5786
321 nostr.purpura.cloud 43.6532 -79.3832
322 nostr.data.haus:443 50.4754 12.3683
323 nexus.libernet.app:443 43.6532 -79.3832
324 nostr.thebiglake.org 32.71 -96.6745
325 relay.bowlafterbowl.com 32.9483 -96.7299
326 relay.mitchelltribe.com 39.0438 -77.4874
327 nostr-relay.xbytez.io:443 50.6924 3.20113
328 bitcoiner.social 40.7608 -111.891
329 relay.guggero.org 46.5971 9.59652
330 relay.mrmave.work 43.6532 -79.3832
331 relay.flashapp.me 43.6548 -79.3885
332 relay.mmwaves.de 48.8575 2.35138
333 nostr.red5d.dev 43.6532 -79.3832
334 relay.plebeian.market 50.1109 8.68213
335 nostr.girino.org 43.6532 -79.3832
336 relay02.lnfi.network 35.6764 139.65
337 relay.vrtmrz.net 43.6532 -79.3832
338 ynostr.yael.at 60.1699 24.9384
339 relay.cosmicbolt.net 37.3986 -121.964
340 nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
341 nostrelay.circum.space:443 52.2245 8.826
342 nostr.tac.lol:443 47.4748 -122.273
343 relay-arg.zombi.cloudrodion.com 1.35208 103.82
344 relay.agora.social 50.7383 15.0648
345 vault.iris.to:443 43.6532 -79.3832
346 relay.nexterz.com 43.6532 -79.3832
347 relay2.veganostr.com 60.1699 24.9384
348 strfry.shock.network 39.0438 -77.4874
349 relay.notoshi.win 13.3396 100.93
350 vault.iris.to 43.6532 -79.3832
351 relay.novospes.com 43.6532 -79.3832
352 nip85.nosfabrica.com 39.0997 -94.5786
353 relay-testnet.k8s.layer3.news 37.3387 -121.885
354 nostr.stakey.net 52.3676 4.90414
355 syb.lol:443 34.0549 -118.243
356 nostr-01.uid.ovh 50.9871 2.12554
357 nostr-02.yakihonne.com 1.32123 103.695
358 relay.favillakey.io -27.4705 153.026
359 relay.plebeian.market:443 50.1109 8.68213
360 r.iqbqioza.com 43.6532 -79.3832
361 relay.ohstr.com:443 43.6532 -79.3832
362 relay.paulstephenborile.com 49.4543 11.0746
363 nostr.2b9t.xyz 34.0549 -118.243
364 slick.mjex.me 39.0418 -77.4744
365 nostr.hekster.org 37.3986 -121.964
366 nostr.stakey.net:443 52.3676 4.90414
367 relay.sharegap.net 43.6532 -79.3832
368 relay.veganostr.com 60.1699 24.9384
369 nosflare-leefcore.leefcore.workers.dev 43.6532 -79.3832
370 nostr.data.haus 50.4754 12.3683
371 x.kojira.io:443 43.6532 -79.3832
372 relay.nostx.io 43.6532 -79.3832
373 wheat.happytavern.co 43.6532 -79.3832
374 nostr.myshosholoza.co.za:443 52.3676 4.90414
375 inbox.scuba323.com 40.8218 -74.45
376 nostr.chaima.info 50.1109 8.68213
377 relay.nostriches.club 43.6532 -79.3832
378 fanfares.nostr1.com 40.7057 -74.0136
379 relay.bitmacro.cloud 43.6532 -79.3832
380 relay.mostr.pub:443 43.6532 -79.3832
381 nostr.spicyz.io:443 43.6532 -79.3832
382 relay.agentry.com 42.8864 -78.8784
383 nostr.thalheim.io:443 60.1699 24.9384
384 tribune-panel-growing-noon.trycloudflare.com 43.6532 -79.3832
385 relay.mypathtofire.de 42.8864 -78.8784
386 nostr-relay.corb.net:443 39.6478 -104.988
387 dm-test-strfry-discovery.samt.st 43.6532 -79.3832
388 nostr.debate.report 50.1109 8.68213
389 relay.nostrcheck.me 43.6532 -79.3832
390 relay.scuba323.com 40.8218 -74.45
391 relay.decentralia.fr 48.122 11.589
392 fanfares.nostr1.com:443 40.7057 -74.0136
393 relay.sigit.io 50.4754 12.3683
394 ribo.nostria.app 43.6532 -79.3832
395 nostr.davenov.com 50.1109 8.68213
396 nostr.azzamo.net:443 52.2633 21.0283
397 ribo.eu.nostria.app 43.6532 -79.3832
398 nostr-01.yakihonne.com:443 1.32123 103.695
399 desktop-r082364.tail4b3c94.ts.net 48.8566 2.35222
400 0x-nostr-relay.fly.dev 38.7946 -106.535
401 soloco.nl 43.6532 -79.3832
402 relay.chorus.community 48.5333 10.7
403 nostr.sathoarder.com:443 48.5734 7.75211
404 nostr.myshosholoza.co.za 52.3676 4.90414
405 cache.trustr.ing 43.6548 -79.3885
406 nostr.islandarea.net 35.4669 -97.6473
407 nostr-relay.corb.net 39.6478 -104.988
408 relay-rpi.edufeed.org:443 49.4521 11.0767
409 rele.speyhard.fi 50.1109 8.68213
410 relay.pocketnostr.com 40.8054 -74.0241
411 relay.nuts.cash 52.3676 4.90414
412 spamspamspamspam.rest 43.6532 -79.3832
413 nostr.dlcdevkit.com 40.0992 -83.1141
414 relay.satlantis.io 39.0438 -77.4874
415 nostr.na.social 43.6532 -79.3832
416 relay.nostreon.com 60.1699 24.9384

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,16 +1,81 @@
package com.bitchat.android
import android.app.Application
import com.bitchat.android.nostr.RelayDirectory
import com.bitchat.android.ui.theme.ThemePreferenceManager
import com.bitchat.android.net.ArtiTorManager
/**
* Main application class for bitchat Android
*/
class BitchatApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize any global services or configurations
// For now, keep it simple
// Start the single process-wide power policy before transport components are constructed.
com.bitchat.android.mesh.PowerManager.getInstance(this).start()
// Initialize Tor first so any early network goes over Tor
try {
val torProvider = ArtiTorManager.getInstance()
torProvider.init(this)
} catch (_: Exception){}
// Initialize relay directory (loads assets/nostr_relays.csv)
RelayDirectory.initialize(this)
// Initialize LocationNotesManager dependencies early so sheet subscriptions can start immediately
try { com.bitchat.android.nostr.LocationNotesInitializer.initialize(this) } catch (_: Exception) { }
// Initialize favorites persistence early so MessageRouter/NostrTransport can use it on startup
try {
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this)
} catch (_: Exception) { }
// Restore private conversations before background transports can deliver new messages.
// AppStateStore merges any in-flight arrivals by message ID, so startup cannot replace
// newer transport state with an older database snapshot.
try {
com.bitchat.android.services.AppStateStore.initializeConversationPersistence(this)
} catch (_: Exception) { }
// Warm up Nostr identity to ensure npub is available for favorite notifications
try {
com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this)
} catch (_: Exception) { }
// Initialize theme preference
ThemePreferenceManager.init(this)
// Initialize chat UI mode (matrix transcript vs bubbles)
com.bitchat.android.ui.theme.ChatUiModeManager.init(this)
// Initialize debug preference manager (persists debug toggles)
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
// Initialize WiFi Aware controller with persisted default
try {
val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false)
com.bitchat.android.wifiaware.WifiAwareController.initialize(this, enabled)
} catch (_: Exception) { }
// Initialize Geohash Registries for persistence
try {
com.bitchat.android.nostr.GeohashAliasRegistry.initialize(this)
com.bitchat.android.nostr.GeohashConversationRegistry.initialize(this)
} catch (_: Exception) { }
// Own relay connectivity, selected-channel subscriptions, and presence scheduling at the
// process level so closing the Activity does not disconnect Nostr.
try { com.bitchat.android.nostr.NostrBackgroundRuntime.initialize(this) } catch (_: Exception) { }
// Initialize mesh service preferences
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
// Proactively start the foreground service to keep mesh alive
try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { }
// TorManager already initialized above
}
}

View File

@ -1,30 +1,35 @@
package com.bitchat.android
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModelProvider
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.Lifecycle
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.onboarding.BluetoothCheckScreen
import com.bitchat.android.onboarding.BluetoothStatus
import com.bitchat.android.onboarding.BluetoothStatusManager
import com.bitchat.android.onboarding.BatteryOptimizationManager
import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager
import com.bitchat.android.onboarding.BatteryOptimizationScreen
import com.bitchat.android.onboarding.BatteryOptimizationStatus
import com.bitchat.android.onboarding.BackgroundLocationPermissionScreen
import com.bitchat.android.onboarding.InitializationErrorScreen
import com.bitchat.android.onboarding.InitializingScreen
import com.bitchat.android.onboarding.LocationCheckScreen
@ -36,40 +41,91 @@ import com.bitchat.android.onboarding.PermissionExplanationScreen
import com.bitchat.android.onboarding.PermissionManager
import com.bitchat.android.ui.ChatScreen
import com.bitchat.android.ui.ChatViewModel
import com.bitchat.android.ui.OrientationAwareActivity
import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.wifiaware.WifiAwareController
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.services.VerificationService
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
class MainActivity : OrientationAwareActivity() {
private lateinit var permissionManager: PermissionManager
private lateinit var onboardingCoordinator: OnboardingCoordinator
private lateinit var bluetoothStatusManager: BluetoothStatusManager
private lateinit var locationStatusManager: LocationStatusManager
private lateinit var batteryOptimizationManager: BatteryOptimizationManager
// Core mesh service - managed at app level
// Core mesh service - provided by the foreground service holder
private lateinit var meshService: BluetoothMeshService
private lateinit var unifiedMeshService: MeshService
private val mainViewModel: MainViewModel by viewModels()
private var pendingMeshForegroundServiceStart = false
private val chatViewModel: ChatViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ChatViewModel(application, meshService) as T
return ChatViewModel(application, meshService, unifiedMeshService) as T
}
}
}
private val forceFinishReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context, intent: android.content.Intent) {
if (intent.action == com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) {
android.util.Log.i("MainActivity", "Received force finish broadcast, closing UI")
finishAffinity()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
this.setRecentsScreenshotEnabled(false)
}
// Register receiver for force finish signal from shutdown coordinator
val filter = android.content.IntentFilter(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH)
if (android.os.Build.VERSION.SDK_INT >= 33) {
registerReceiver(
forceFinishReceiver,
filter,
com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH,
null,
android.content.Context.RECEIVER_NOT_EXPORTED
)
} else {
@Suppress("DEPRECATION")
registerReceiver(
forceFinishReceiver,
filter,
com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH,
null
)
}
// Initialize core mesh service first
meshService = BluetoothMeshService(this)
// Check if this is a quit request from the notification
if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity")
finish()
return
}
com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown()
// Enable edge-to-edge display for modern Android look
enableEdgeToEdge()
// Initialize permission management
permissionManager = PermissionManager(this)
// Start the foreground service when allowed, then get mesh instances from the holder.
startMeshForegroundServiceBestEffort()
meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
unifiedMeshService = com.bitchat.android.service.MeshServiceHolder.getUnifiedOrCreate(applicationContext)
// Expose BLE mesh to WiFi Aware controller for cross-transport relays - DEPRECATED
// Bridging is now handled by TransportBridgeService automatically
bluetoothStatusManager = BluetoothStatusManager(
activity = this,
context = this,
@ -92,16 +148,22 @@ class MainActivity : ComponentActivity() {
activity = this,
permissionManager = permissionManager,
onOnboardingComplete = ::handleOnboardingComplete,
onBackgroundLocationRequired = {
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
},
onOnboardingFailed = ::handleOnboardingFailed
)
setContent {
BitchatTheme {
Surface(
Scaffold(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
OnboardingFlowScreen()
containerColor = MaterialTheme.colorScheme.background
) { innerPadding ->
OnboardingFlowScreen(modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
)
}
}
}
@ -114,6 +176,17 @@ class MainActivity : ComponentActivity() {
}
}
}
// Keep the unified mesh delegate attached when Wi-Fi Aware starts after the UI.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
WifiAwareController.running.collect { running ->
if (running && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
unifiedMeshService.delegate = chatViewModel
}
}
}
}
// Only start onboarding process if we're in the initial CHECKING state
// This prevents restarting onboarding on configuration changes
@ -123,7 +196,8 @@ class MainActivity : ComponentActivity() {
}
@Composable
private fun OnboardingFlowScreen() {
private fun OnboardingFlowScreen(modifier: Modifier = Modifier) {
val context = LocalContext.current
val onboardingState by mainViewModel.onboardingState.collectAsState()
val bluetoothStatus by mainViewModel.bluetoothStatus.collectAsState()
val locationStatus by mainViewModel.locationStatus.collectAsState()
@ -132,14 +206,36 @@ class MainActivity : ComponentActivity() {
val isBluetoothLoading by mainViewModel.isBluetoothLoading.collectAsState()
val isLocationLoading by mainViewModel.isLocationLoading.collectAsState()
val isBatteryOptimizationLoading by mainViewModel.isBatteryOptimizationLoading.collectAsState()
DisposableEffect(context, bluetoothStatusManager) {
val receiver = bluetoothStatusManager.monitorBluetoothState(
context = context,
bluetoothStatusManager = bluetoothStatusManager,
onBluetoothStateChanged = { status ->
if (status == BluetoothStatus.ENABLED && onboardingState == OnboardingState.BLUETOOTH_CHECK) {
checkBluetoothAndProceed()
}
}
)
onDispose {
try {
context.unregisterReceiver(receiver)
} catch (e: IllegalStateException) {
Log.w("BluetoothStatusUI", "Receiver was not registered")
}
}
}
when (onboardingState) {
OnboardingState.CHECKING -> {
InitializingScreen()
OnboardingState.PERMISSION_REQUESTING -> {
InitializingScreen(modifier)
}
OnboardingState.BLUETOOTH_CHECK -> {
BluetoothCheckScreen(
modifier = modifier,
status = bluetoothStatus,
onEnableBluetooth = {
mainViewModel.updateBluetoothLoading(true)
@ -148,12 +244,17 @@ class MainActivity : ComponentActivity() {
onRetry = {
checkBluetoothAndProceed()
},
onSkip = {
mainViewModel.skipBluetoothCheck()
checkLocationAndProceed()
},
isLoading = isBluetoothLoading
)
}
OnboardingState.LOCATION_CHECK -> {
LocationCheckScreen(
modifier = modifier,
status = locationStatus,
onEnableLocation = {
mainViewModel.updateLocationLoading(true)
@ -168,6 +269,7 @@ class MainActivity : ComponentActivity() {
OnboardingState.BATTERY_OPTIMIZATION_CHECK -> {
BatteryOptimizationScreen(
modifier = modifier,
status = batteryOptimizationStatus,
onDisableBatteryOptimization = {
mainViewModel.updateBatteryOptimizationLoading(true)
@ -186,6 +288,7 @@ class MainActivity : ComponentActivity() {
OnboardingState.PERMISSION_EXPLANATION -> {
PermissionExplanationScreen(
modifier = modifier,
permissionCategories = permissionManager.getCategorizedPermissions(),
onContinue = {
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_REQUESTING)
@ -193,23 +296,30 @@ class MainActivity : ComponentActivity() {
}
)
}
OnboardingState.PERMISSION_REQUESTING -> {
InitializingScreen()
OnboardingState.BACKGROUND_LOCATION_EXPLANATION -> {
BackgroundLocationPermissionScreen(
modifier = modifier,
onContinue = {
onboardingCoordinator.requestBackgroundLocation()
},
onRetry = {
onboardingCoordinator.checkBackgroundLocationAndProceed()
},
onSkip = {
onboardingCoordinator.skipBackgroundLocation()
}
)
}
OnboardingState.INITIALIZING -> {
InitializingScreen()
}
OnboardingState.COMPLETE -> {
OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> {
// Set up back navigation handling for the chat screen
val backCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// Let ChatViewModel handle navigation state
val handled = chatViewModel.handleBackPressed()
if (!handled) {
// If ChatViewModel doesn't handle it, disable this callback
// If ChatViewModel doesn't handle it, disable this callback
// and let the system handle it (which will exit the app)
this.isEnabled = false
onBackPressedDispatcher.onBackPressed()
@ -217,15 +327,15 @@ class MainActivity : ComponentActivity() {
}
}
}
// Add the callback - this will be automatically removed when the activity is destroyed
onBackPressedDispatcher.addCallback(this, backCallback)
ChatScreen(viewModel = chatViewModel)
}
OnboardingState.ERROR -> {
InitializationErrorScreen(
modifier = modifier,
errorMessage = errorMessage,
onRetry = {
mainViewModel.updateOnboardingState(OnboardingState.CHECKING)
@ -244,7 +354,7 @@ class MainActivity : ComponentActivity() {
when (state) {
OnboardingState.COMPLETE -> {
// App is fully initialized, mesh service is running
android.util.Log.d("MainActivity", "Onboarding completed - app ready")
android.util.Log.i("MainActivity", "Onboarding completed - app ready")
}
OnboardingState.ERROR -> {
android.util.Log.e("MainActivity", "Onboarding error state reached")
@ -254,8 +364,6 @@ class MainActivity : ComponentActivity() {
}
private fun checkOnboardingStatus() {
Log.d("MainActivity", "Checking onboarding status")
lifecycleScope.launch {
// Small delay to show the checking state
delay(500)
@ -269,12 +377,15 @@ class MainActivity : ComponentActivity() {
* Check Bluetooth status and proceed with onboarding flow
*/
private fun checkBluetoothAndProceed() {
// Log.d("MainActivity", "Checking Bluetooth status")
// Check if user has skipped Bluetooth check for this session
if (mainViewModel.isBluetoothCheckSkipped.value) {
checkLocationAndProceed()
return
}
// For first-time users, skip Bluetooth check and go straight to permissions
// We'll check Bluetooth after permissions are granted
if (permissionManager.isFirstTimeLaunch()) {
Log.d("MainActivity", "First-time launch, skipping Bluetooth check - will check after permissions")
proceedWithPermissionCheck()
return
}
@ -283,6 +394,12 @@ class MainActivity : ComponentActivity() {
bluetoothStatusManager.logBluetoothStatus()
mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus())
val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
if (!bleRequired) {
// Skip BLE checks entirely when BLE is disabled in debug settings
checkLocationAndProceed()
return
}
when (mainViewModel.bluetoothStatus.value) {
BluetoothStatus.ENABLED -> {
// Bluetooth is enabled, check location services next
@ -290,7 +407,6 @@ class MainActivity : ComponentActivity() {
}
BluetoothStatus.DISABLED -> {
// Show Bluetooth enable screen (should have permissions as existing user)
Log.d("MainActivity", "Bluetooth disabled, showing enable screen")
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
mainViewModel.updateBluetoothLoading(false)
}
@ -307,20 +423,24 @@ class MainActivity : ComponentActivity() {
* Proceed with permission checking
*/
private fun proceedWithPermissionCheck() {
Log.d("MainActivity", "Proceeding with permission check")
lifecycleScope.launch {
delay(200) // Small delay for smooth transition
if (permissionManager.isFirstTimeLaunch()) {
Log.d("MainActivity", "First time launch, showing permission explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
} else if (permissionManager.areAllPermissionsGranted()) {
Log.d("MainActivity", "Existing user with permissions, initializing app")
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
initializeApp()
} else if (permissionManager.getUnrequestedOptionalPermissions().isNotEmpty()) {
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
} else if (permissionManager.areRequiredPermissionsGranted()) {
if (permissionManager.needsBackgroundLocationPermission() &&
!permissionManager.isBackgroundLocationGranted() &&
!com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity)
) {
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
} else {
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
initializeApp()
}
} else {
Log.d("MainActivity", "Existing user missing permissions, showing explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
}
}
@ -330,7 +450,6 @@ class MainActivity : ComponentActivity() {
* Handle Bluetooth enabled callback
*/
private fun handleBluetoothEnabled() {
Log.d("MainActivity", "Bluetooth enabled by user")
mainViewModel.updateBluetoothLoading(false)
mainViewModel.updateBluetoothStatus(BluetoothStatus.ENABLED)
checkLocationAndProceed()
@ -340,12 +459,9 @@ class MainActivity : ComponentActivity() {
* Check Location services status and proceed with onboarding flow
*/
private fun checkLocationAndProceed() {
Log.d("MainActivity", "Checking location services status")
// For first-time users, skip location check and go straight to permissions
// We'll check location after permissions are granted
if (permissionManager.isFirstTimeLaunch()) {
Log.d("MainActivity", "First-time launch, skipping location check - will check after permissions")
proceedWithPermissionCheck()
return
}
@ -361,7 +477,6 @@ class MainActivity : ComponentActivity() {
}
LocationStatus.DISABLED -> {
// Show location enable screen (should have permissions as existing user)
Log.d("MainActivity", "Location services disabled, showing enable screen")
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
mainViewModel.updateLocationLoading(false)
}
@ -378,9 +493,10 @@ class MainActivity : ComponentActivity() {
* Handle Location enabled callback
*/
private fun handleLocationEnabled() {
Log.d("MainActivity", "Location services enabled by user")
mainViewModel.updateLocationLoading(false)
mainViewModel.updateLocationStatus(LocationStatus.ENABLED)
// Ensure Wi-Fi Aware starts now that location is enabled
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
checkBatteryOptimizationAndProceed()
}
@ -422,12 +538,10 @@ class MainActivity : ComponentActivity() {
message.contains("Permission") && permissionManager.isFirstTimeLaunch() -> {
// During first-time onboarding, if Bluetooth enable fails due to permissions,
// proceed to permission explanation screen where user will grant permissions first
Log.d("MainActivity", "Bluetooth enable requires permissions, proceeding to permission explanation")
proceedWithPermissionCheck()
}
message.contains("Permission") -> {
// For existing users, redirect to permission explanation to grant missing permissions
Log.d("MainActivity", "Bluetooth enable requires permissions, showing permission explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
}
else -> {
@ -438,8 +552,6 @@ class MainActivity : ComponentActivity() {
}
private fun handleOnboardingComplete() {
Log.d("MainActivity", "Onboarding completed, checking Bluetooth and Location before initializing app")
// After permissions are granted, re-check Bluetooth, Location, and Battery Optimization status
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
val currentLocationStatus = locationStatusManager.checkLocationStatus()
@ -449,31 +561,28 @@ class MainActivity : ComponentActivity() {
else -> BatteryOptimizationStatus.ENABLED
}
val bleRequired2 = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
when {
currentBluetoothStatus != BluetoothStatus.ENABLED -> {
bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> {
// Bluetooth still disabled, but now we have permissions to enable it
Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
mainViewModel.updateBluetoothLoading(false)
}
currentLocationStatus != LocationStatus.ENABLED -> {
// Location services still disabled, but now we have permissions to enable it
Log.d("MainActivity", "Permissions granted, but Location services still disabled. Showing Location enable screen.")
mainViewModel.updateLocationStatus(currentLocationStatus)
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
mainViewModel.updateLocationLoading(false)
}
currentBatteryOptimizationStatus == BatteryOptimizationStatus.ENABLED -> {
// Battery optimization still enabled, show battery optimization screen
android.util.Log.d("MainActivity", "Permissions granted, but battery optimization still enabled. Showing battery optimization screen.")
mainViewModel.updateBatteryOptimizationStatus(currentBatteryOptimizationStatus)
mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK)
mainViewModel.updateBatteryOptimizationLoading(false)
}
else -> {
// Both are enabled, proceed to app initialization
Log.d("MainActivity", "Both Bluetooth and Location services are enabled, proceeding to initialization")
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
initializeApp()
}
@ -485,17 +594,36 @@ class MainActivity : ComponentActivity() {
mainViewModel.updateErrorMessage(message)
mainViewModel.updateOnboardingState(OnboardingState.ERROR)
}
private fun startMeshForegroundServiceBestEffort() {
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
pendingMeshForegroundServiceStart = true
Log.i("MainActivity", "Deferring foreground mesh service start until activity is started")
return
}
try {
com.bitchat.android.service.MeshForegroundService.start(applicationContext)
pendingMeshForegroundServiceStart = false
} catch (e: Exception) {
pendingMeshForegroundServiceStart = true
Log.w("MainActivity", "Unable to start foreground mesh service; will retry when activity is started", e)
}
}
/**
* Check Battery Optimization status and proceed with onboarding flow
*/
private fun checkBatteryOptimizationAndProceed() {
android.util.Log.d("MainActivity", "Checking battery optimization status")
// For first-time users, skip battery optimization check and go straight to permissions
// We'll check battery optimization after permissions are granted
if (permissionManager.isFirstTimeLaunch()) {
android.util.Log.d("MainActivity", "First-time launch, skipping battery optimization check - will check after permissions")
proceedWithPermissionCheck()
return
}
// Check if user has previously skipped battery optimization
if (BatteryOptimizationPreferenceManager.isSkipped(this)) {
proceedWithPermissionCheck()
return
}
@ -516,7 +644,6 @@ class MainActivity : ComponentActivity() {
}
BatteryOptimizationStatus.ENABLED -> {
// Show battery optimization disable screen
android.util.Log.d("MainActivity", "Battery optimization enabled, showing disable screen")
mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK)
mainViewModel.updateBatteryOptimizationLoading(false)
}
@ -527,7 +654,6 @@ class MainActivity : ComponentActivity() {
* Handle Battery Optimization disabled callback
*/
private fun handleBatteryOptimizationDisabled() {
android.util.Log.d("MainActivity", "Battery optimization disabled by user")
mainViewModel.updateBatteryOptimizationLoading(false)
mainViewModel.updateBatteryOptimizationStatus(BatteryOptimizationStatus.DISABLED)
proceedWithPermissionCheck()
@ -551,15 +677,17 @@ class MainActivity : ComponentActivity() {
}
private fun initializeApp() {
Log.d("MainActivity", "Starting app initialization")
lifecycleScope.launch {
try {
// Initialize the app with a proper delay to ensure Bluetooth stack is ready
// This solves the issue where app needs restart to work on first install
delay(1000) // Give the system time to process permission grants
// Initialize PoW preferences early in the initialization process
PoWPreferenceManager.init(this@MainActivity)
Log.d("MainActivity", "Permissions verified, initializing chat system")
// Initialize Location Notes Manager (extracted to separate file)
com.bitchat.android.nostr.LocationNotesInitializer.initialize(this@MainActivity)
// Ensure all permissions are still granted (user might have revoked in settings)
if (!permissionManager.areAllPermissionsGranted()) {
@ -568,19 +696,19 @@ class MainActivity : ComponentActivity() {
handleOnboardingFailed("Some permissions were revoked. Please grant all permissions to continue.")
return@launch
}
// Set up mesh service delegate and start services
meshService.delegate = chatViewModel
meshService.startServices()
Log.d("MainActivity", "Mesh service started successfully")
// Set up unified mesh delegate and start enabled transports
unifiedMeshService.delegate = chatViewModel
unifiedMeshService.startServices()
startMeshForegroundServiceBestEffort()
// Handle any notification intent
handleNotificationIntent(intent)
handleVerificationIntent(intent)
// Small delay to ensure mesh service is fully initialized
delay(500)
Log.d("MainActivity", "App initialization complete")
Log.i("MainActivity", "App initialization complete")
mainViewModel.updateOnboardingState(OnboardingState.COMPLETE)
} catch (e: Exception) {
Log.e("MainActivity", "Failed to initialize app", e)
@ -591,23 +719,45 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
// Check if this is a quit request from the notification
if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
android.util.Log.d("MainActivity", "Quit request received, finishing activity")
finish()
return
}
com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown()
// Handle notification intents when app is already running
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
handleNotificationIntent(intent)
handleVerificationIntent(intent)
}
}
override fun onStart() {
super.onStart()
if (pendingMeshForegroundServiceStart) {
startMeshForegroundServiceBestEffort()
}
}
override fun onResume() {
super.onResume()
// Revoke stale live-location work before any resumed UI can use cached channels.
LocationChannelManager.getInstance(applicationContext).syncPermissionState()
// Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Set app foreground state
meshService.connectionManager.setAppBackgroundState(false)
chatViewModel.setAppBackgroundState(false)
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
try { unifiedMeshService.delegate = chatViewModel } catch (_: Exception) { }
// Check if Bluetooth was disabled while app was backgrounded
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
if (currentBluetoothStatus != BluetoothStatus.ENABLED) {
val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
if (bleRequired && currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) {
Log.w("MainActivity", "Bluetooth disabled while app was backgrounded")
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
@ -622,6 +772,9 @@ class MainActivity : ComponentActivity() {
mainViewModel.updateLocationStatus(currentLocationStatus)
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
mainViewModel.updateLocationLoading(false)
} else {
// If location is enabled, ensure Wi-Fi Aware starts if it was blocked by location earlier
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
}
}
}
@ -630,14 +783,13 @@ class MainActivity : ComponentActivity() {
super.onPause()
// Only set background state if app is fully initialized
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Set app background state
meshService.connectionManager.setAppBackgroundState(true)
chatViewModel.setAppBackgroundState(true)
// Detach UI delegate so the foreground service can own DM notifications while UI is closed
try { unifiedMeshService.delegate = null } catch (_: Exception) { }
}
}
/**
* Handle intents from notification clicks - open specific private chat
* Handle intents from notification clicks - open specific private chat or geohash chat
*/
private fun handleNotificationIntent(intent: Intent) {
val shouldOpenPrivateChat = intent.getBooleanExtra(
@ -645,41 +797,81 @@ class MainActivity : ComponentActivity() {
false
)
if (shouldOpenPrivateChat) {
val peerID = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_PEER_ID)
val senderNickname = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_SENDER_NICKNAME)
val shouldOpenGeohashChat = intent.getBooleanExtra(
com.bitchat.android.ui.NotificationManager.EXTRA_OPEN_GEOHASH_CHAT,
false
)
when {
shouldOpenPrivateChat -> {
val peerID = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_PEER_ID)
val senderNickname = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_SENDER_NICKNAME)
if (peerID != null) {
Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification")
// Open the private chat sheet with this peer
chatViewModel.showMeshPeerList()
chatViewModel.showPrivateChatSheet(peerID)
// Clear notifications for this sender since user is now viewing the chat
chatViewModel.clearNotificationsForSender(peerID)
}
}
if (peerID != null) {
Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification")
shouldOpenGeohashChat -> {
val geohash = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_GEOHASH)
// Open the private chat with this peer
chatViewModel.startPrivateChat(peerID)
// Clear notifications for this sender since user is now viewing the chat
chatViewModel.clearNotificationsForSender(peerID)
if (geohash != null) {
Log.d("MainActivity", "Opening geohash chat from notification")
// Switch to the geohash channel - create appropriate geohash channel level
val level = when (geohash.length) {
7 -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
else -> com.bitchat.android.geohash.GeohashChannelLevel.CITY // Default fallback
}
val geohashChannel = com.bitchat.android.geohash.GeohashChannel(level, geohash)
val channelId = com.bitchat.android.geohash.ChannelID.Location(geohashChannel)
chatViewModel.selectLocationChannel(channelId)
// Update current geohash state for notifications
chatViewModel.setCurrentGeohash(geohash)
// Clear notifications for this geohash since user is now viewing it
chatViewModel.clearNotificationsForGeohash(geohash)
}
}
}
}
private fun handleVerificationIntent(intent: Intent) {
val uri = intent.data ?: return
if (uri.scheme != "bitchat" || uri.host != "verify") return
chatViewModel.showVerificationSheet()
val qr = VerificationService.verifyScannedQR(uri.toString())
if (qr != null) {
chatViewModel.beginQRVerification(qr)
}
}
override fun onDestroy() {
super.onDestroy()
try { unregisterReceiver(forceFinishReceiver) } catch (_: Exception) { }
// Cleanup location status manager
try {
locationStatusManager.cleanup()
Log.d("MainActivity", "Location status manager cleaned up successfully")
} catch (e: Exception) {
Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
}
// Stop mesh services if app was fully initialized
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
try {
meshService.stopServices()
Log.d("MainActivity", "Mesh services stopped successfully")
} catch (e: Exception) {
Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
}
}
// Do not stop mesh here; ForegroundService owns lifecycle for background reliability
}
}

View File

@ -35,6 +35,9 @@ class MainViewModel : ViewModel() {
private val _isBatteryOptimizationLoading = MutableStateFlow(false)
val isBatteryOptimizationLoading: StateFlow<Boolean> = _isBatteryOptimizationLoading.asStateFlow()
private val _isBluetoothCheckSkipped = MutableStateFlow(false)
val isBluetoothCheckSkipped: StateFlow<Boolean> = _isBluetoothCheckSkipped.asStateFlow()
// Public update functions for MainActivity
fun updateOnboardingState(state: OnboardingState) {
_onboardingState.value = state
@ -67,4 +70,8 @@ class MainViewModel : ViewModel() {
fun updateBatteryOptimizationLoading(loading: Boolean) {
_isBatteryOptimizationLoading.value = loading
}
fun skipBluetoothCheck() {
_isBluetoothCheckSkipped.value = true
}
}

View File

@ -0,0 +1,92 @@
package com.bitchat.android.core.ui.component.button
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.bitchat.android.core.ui.icon.BitChatIcon
import com.bitchat.android.ui.rememberPressScale
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
private val MultiClickThreshold = 300.milliseconds
@Composable
fun BitChatBrandButton(
onClick: () -> Unit,
onTripleClick: () -> Unit,
contentDescription: String,
modifier: Modifier = Modifier,
tint: Color = MaterialTheme.colorScheme.primary,
iconSize: Dp = 22.dp,
) {
var tapCount by remember { mutableIntStateOf(0) }
var resetJob by remember { mutableStateOf<Job?>(null) }
val coroutineScope = rememberCoroutineScope()
val currentOnClick by rememberUpdatedState(onClick)
val currentOnTripleClick by rememberUpdatedState(onTripleClick)
val interactionSource = remember { MutableInteractionSource() }
val pressScale = rememberPressScale(interactionSource)
// A plain Box rather than an IconButton: IconButton insists on drawing a ripple, which was the
// only press background left in the header once every other control moved to scale-only
// feedback.
Box(
modifier = modifier
.clip(CircleShape)
.clickable(
interactionSource = interactionSource,
indication = null,
onClickLabel = contentDescription
) {
tapCount += 1
resetJob?.cancel()
if (tapCount == 3) {
tapCount = 0
resetJob = null
currentOnTripleClick()
} else {
resetJob = coroutineScope.launch {
delay(MultiClickThreshold)
if (tapCount == 1) {
currentOnClick()
}
tapCount = 0
resetJob = null
}
}
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = BitChatIcon,
contentDescription = contentDescription,
tint = tint,
modifier = Modifier
.size(iconSize)
.scale(pressScale),
)
}
}

View File

@ -0,0 +1,38 @@
package com.bitchat.android.core.ui.component.button
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.bitchat.android.R
@Composable
fun CloseButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
IconButton(
onClick = onClick,
// 44.dp to match every other tap target in the app's chrome.
modifier = modifier.size(44.dp),
colors = IconButtonDefaults.iconButtonColors(
contentColor = colorScheme.primary,
containerColor = Color.Transparent
)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.close_plain),
modifier = Modifier.size(18.dp)
)
}
}

View File

@ -0,0 +1,67 @@
package com.bitchat.android.core.ui.component.sheet
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
/**
* Dismisses the enclosing [BitchatBottomSheet], playing the slide-down first.
*
* `ModalBottomSheet` only animates itself out when *it* initiates the dismissal a swipe or a tap
* on the scrim. Anything that closes a sheet programmatically (a close button, picking an item from
* a list) previously flipped the caller's `isPresented` flag straight to false, which yanks the
* composable out of the tree and makes the sheet vanish instantly.
*
* Anything inside a sheet that wants to close it should prefer this over calling its own
* `onDismiss` directly.
*/
val LocalSheetDismiss = staticCompositionLocalOf<(() -> Unit)?> { null }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BitchatBottomSheet(
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
onDismissRequest: () -> Unit,
content: @Composable (ColumnScope.() -> Unit),
) {
val scope = rememberCoroutineScope()
// Runs the hide animation to completion, then tells the caller to drop the sheet. `hide()`
// throws if the sheet is already on its way out (two rapid taps on a close button), which is
// benign — the dismissal still has to go through.
val animatedDismiss: () -> Unit = remember(sheetState, onDismissRequest) {
{
scope.launch {
runCatching { sheetState.hide() }
onDismissRequest()
}
}
}
ModalBottomSheet(
modifier = modifier.statusBarsPadding(),
onDismissRequest = onDismissRequest,
sheetState = sheetState,
dragHandle = null,
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
containerColor = MaterialTheme.colorScheme.background,
) {
CompositionLocalProvider(LocalSheetDismiss provides animatedDismiss) {
content()
}
}
}

View File

@ -0,0 +1,89 @@
package com.bitchat.android.core.ui.component.sheet
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.CompositionLocalProvider
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.core.ui.component.button.CloseButton
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BitchatSheetTopBar(
onClose: () -> Unit,
modifier: Modifier = Modifier,
backgroundAlpha: Float = 0.98f,
title: @Composable () -> Unit,
navigationIcon: (@Composable () -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {}
) {
TopAppBar(
title = title,
navigationIcon = { navigationIcon?.invoke() },
actions = {
actions()
val dismiss = LocalSheetDismiss.current
CloseButton(
onClick = { dismiss?.invoke() ?: onClose() },
modifier = Modifier.padding(horizontal = 16.dp)
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background.copy(alpha = backgroundAlpha),
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface
),
modifier = modifier
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BitchatSheetCenterTopBar(
onClose: () -> Unit,
modifier: Modifier = Modifier,
backgroundAlpha: Float = 0.98f,
title: @Composable () -> Unit,
navigationIcon: (@Composable () -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {}
) {
CenterAlignedTopAppBar(
title = title,
navigationIcon = { navigationIcon?.invoke() },
actions = {
actions()
val dismiss = LocalSheetDismiss.current
CloseButton(
onClick = { dismiss?.invoke() ?: onClose() },
modifier = Modifier.padding(horizontal = 16.dp)
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background.copy(alpha = backgroundAlpha),
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface
),
modifier = modifier
)
}
@Composable
fun BitchatSheetTitle(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
fontFamily = BitchatFontFamily
)
)
}

View File

@ -0,0 +1,100 @@
package com.bitchat.android.core.ui.component.text
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
internal data class ClickedAnnotation(
val tag: String,
val item: String,
)
internal fun findAnnotationAt(
text: AnnotatedString,
offset: Int,
annotationTags: List<String>,
): ClickedAnnotation? {
for (tag in annotationTags) {
text.getStringAnnotations(tag = tag, start = offset, end = offset)
.firstOrNull()
?.let { annotation ->
return ClickedAnnotation(tag = tag, item = annotation.item)
}
}
return null
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AnnotatedClickableText(
text: AnnotatedString,
annotationTags: List<String>,
onAnnotationClick: (tag: String, item: String) -> Boolean,
modifier: Modifier = Modifier,
onLongPress: (() -> Unit)? = null,
color: Color = Color.Unspecified,
fontFamily: FontFamily? = null,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
style: TextStyle = LocalTextStyle.current,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
) {
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
val currentOnAnnotationClick by rememberUpdatedState(onAnnotationClick)
val currentOnLongPress by rememberUpdatedState(onLongPress)
Text(
text = text,
modifier = modifier.pointerInput(text, annotationTags, onLongPress != null) {
detectTapGestures(
onTap = { position ->
val offset = layoutResult
?.getOffsetForPosition(position)
?: return@detectTapGestures
var remainingTags = annotationTags
while (remainingTags.isNotEmpty()) {
val annotation = findAnnotationAt(
text = text,
offset = offset,
annotationTags = remainingTags,
) ?: break
if (currentOnAnnotationClick(annotation.tag, annotation.item)) {
return@detectTapGestures
}
remainingTags = remainingTags.drop(
remainingTags.indexOf(annotation.tag) + 1
)
}
},
onLongPress = currentOnLongPress?.let { callback ->
{ callback() }
},
)
},
color = color,
fontFamily = fontFamily,
softWrap = softWrap,
overflow = overflow,
style = style,
onTextLayout = { result ->
layoutResult = result
onTextLayout?.invoke(result)
},
)
}

View File

@ -0,0 +1,48 @@
package com.bitchat.android.core.ui.icon
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
val BitChatIcon: ImageVector
get() {
_BitChatIcon?.let { return it }
return ImageVector.Builder(
name = "BitChatIcon",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 8f,
viewportHeight = 8f,
).apply {
path(fill = SolidColor(Color.Black)) {
moveTo(2f, 0f)
lineTo(6f, 0f)
lineTo(6f, 1f)
lineTo(7f, 1f)
lineTo(7f, 2f)
lineTo(8f, 2f)
lineTo(8f, 5f)
lineTo(7f, 5f)
lineTo(7f, 6f)
lineTo(6f, 6f)
lineTo(6f, 8f)
lineTo(5f, 8f)
lineTo(5f, 7f)
lineTo(3f, 7f)
lineTo(3f, 6f)
lineTo(1f, 6f)
lineTo(1f, 5f)
lineTo(0f, 5f)
lineTo(0f, 2f)
lineTo(1f, 2f)
lineTo(1f, 1f)
lineTo(2f, 1f)
close()
}
}.build().also { _BitChatIcon = it }
}
private var _BitChatIcon: ImageVector? = null

View File

@ -1,57 +0,0 @@
package com.bitchat.android.core.ui.utils
import androidx.compose.foundation.clickable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
fun Modifier.singleOrTripleClickable(
onSingleClick: () -> Unit,
onTripleClick: () -> Unit,
clickTimeThreshold: Long = 300L
): Modifier = composed {
var tapCount by remember { mutableIntStateOf(0) }
var lastTapTime by remember { mutableLongStateOf(0L) }
var singleClickJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
val coroutineScope = rememberCoroutineScope()
this.clickable {
val currentTime = System.currentTimeMillis()
if (currentTime - lastTapTime < clickTimeThreshold) {
tapCount++
} else {
tapCount = 1
}
lastTapTime = currentTime
// Cancel any pending single click action
singleClickJob?.cancel()
singleClickJob = null
when (tapCount) {
1 -> {
// Wait to see if more taps come
singleClickJob = coroutineScope.launch {
delay(clickTimeThreshold)
if (tapCount == 1) {
onSingleClick()
}
}
}
3 -> {
// Triple click detected - execute immediately
onTripleClick()
tapCount = 0
}
}
// Reset after threshold if no triple click
if (tapCount > 3) {
tapCount = 0
}
}
}

View File

@ -1,10 +1,24 @@
package com.bitchat.android.crypto
import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.bitchat.android.noise.NoiseEncryptionService
import com.bitchat.android.noise.NoiseHandshakeProcessingResult
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoiseDecryptionResult
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap
import androidx.core.content.edit
/**
* Encryption service that now uses NoiseEncryptionService internally
@ -13,24 +27,62 @@ import java.util.concurrent.ConcurrentHashMap
* This is the main interface for all encryption/decryption operations in bitchat.
* It now uses the Noise protocol for secure transport encryption with proper session management.
*/
class EncryptionService(private val context: Context) {
open class EncryptionService(private val context: Context) {
companion object {
private const val TAG = "EncryptionService"
private const val ED25519_PRIVATE_KEY_PREF = "ed25519_signing_private_key"
private const val OLD_PREFS_NAME = "bitchat_crypto"
private const val SECURE_PREFS_NAME = "bitchat_crypto_secure"
}
// Core Noise encryption service
private val noiseService: NoiseEncryptionService = NoiseEncryptionService(context)
private val noiseService: NoiseEncryptionService by lazy { NoiseEncryptionService(context) }
// Session tracking for established connections
private val establishedSessions = ConcurrentHashMap<String, String>() // peerID -> fingerprint
// Ed25519 signing keys (separate from Noise static keys)
private lateinit var ed25519PrivateKey: Ed25519PrivateKeyParameters
private lateinit var ed25519PublicKey: Ed25519PublicKeyParameters
// Callbacks for UI state updates
var onSessionEstablished: ((String) -> Unit)? = null // peerID
var onSessionLost: ((String) -> Unit)? = null // peerID
var onHandshakeRequired: ((String) -> Unit)? = null // peerID
private lateinit var prefs: SharedPreferences
init {
initialize()
}
private fun setUpEncryptedPrefs() {
val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
// Create encrypted shared preferences
prefs = EncryptedSharedPreferences.create(
context,
SECURE_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
/**
* Initialization logic moved to method to allow overriding in tests
*/
protected open fun initialize() {
setUpEncryptedPrefs()
// Initialize or load Ed25519 signing keys
val keyPair = loadOrCreateEd25519KeyPair()
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
ed25519PublicKey = keyPair.public as Ed25519PublicKeyParameters
Log.d(TAG, "✅ Ed25519 signing keys initialized")
// Set up NoiseEncryptionService callbacks
noiseService.onPeerAuthenticated = { peerID, fingerprint ->
Log.d(TAG, "✅ Noise session established with $peerID, fingerprint: ${fingerprint.take(16)}...")
@ -63,24 +115,26 @@ class EncryptionService(private val context: Context) {
/**
* Get our signing public key for Ed25519 signatures (for identity announcements)
* Note: In the current implementation, this returns the same as static key
* In a full implementation, this would be a separate Ed25519 key
*/
fun getSigningPublicKey(): ByteArray? {
// For now, return the static public key as placeholder
// In a full implementation, this would be a separate Ed25519 signing key
return noiseService.getStaticPublicKeyData()
return ed25519PublicKey.encoded
}
/**
* Sign data using our signing key (for identity announcements)
* Note: In the current simplified implementation, this returns empty signature
* In a full implementation, this would use Ed25519 signing
* Sign data using our Ed25519 signing key (for identity announcements)
*/
fun signData(data: ByteArray): ByteArray? {
// For now, return empty signature as placeholder
// In a full implementation, this would use Ed25519 to sign the data
return ByteArray(64) // Ed25519 signature length placeholder
return try {
val signer = Ed25519Signer()
signer.init(true, ed25519PrivateKey)
signer.update(data, 0, data.size)
val signature = signer.generateSignature()
Log.d(TAG, "✅ Generated Ed25519 signature (${signature.size} bytes)")
signature
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to sign data with Ed25519: ${e.message}")
null
}
}
/**
@ -112,6 +166,20 @@ class EncryptionService(private val context: Context) {
fun clearPersistentIdentity() {
noiseService.clearPersistentIdentity()
establishedSessions.clear()
// Clear Ed25519 signing key from preferences
try {
prefs.edit { remove(ED25519_PRIVATE_KEY_PREF) }
Log.d(TAG, "🗑️ Cleared Ed25519 signing keys from preferences")
// Generate new keys immediately
val keyPair = loadOrCreateEd25519KeyPair()
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
ed25519PublicKey = keyPair.public as Ed25519PublicKeyParameters
Log.d(TAG, "✅ Rotated Ed25519 signing keys in memory")
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to clear Ed25519 keys: ${e.message}")
}
}
/**
@ -125,6 +193,13 @@ class EncryptionService(private val context: Context) {
}
return encrypted
}
@Throws(Exception::class)
fun encryptForSession(
data: ByteArray,
peerID: String,
expectedSession: AuthenticatedNoiseSession
): ByteArray = noiseService.encryptForSession(data, peerID, expectedSession)
/**
* Decrypt data from a specific peer using Noise transport encryption
@ -137,6 +212,12 @@ class EncryptionService(private val context: Context) {
}
return decrypted
}
@Throws(Exception::class)
fun decryptWithSession(data: ByteArray, peerID: String): NoiseDecryptionResult {
return noiseService.decryptWithSession(data, peerID)
?: throw Exception("Failed generation-bound decryption from $peerID")
}
/**
* Sign data using our static identity key
@ -189,6 +270,25 @@ class EncryptionService(private val context: Context) {
fun getPeerFingerprint(peerID: String): String? {
return noiseService.getPeerFingerprint(peerID)
}
/**
* Return the remote static key authenticated by the live Noise handshake.
* This deliberately bypasses announcement and PeerFingerprintManager
* caches; callers making downgrade decisions must bind to live channel
* authentication, not a self-certified identity payload.
*/
fun getAuthenticatedRemoteStaticKey(peerID: String): ByteArray? {
return getAuthenticatedSession(peerID)?.remoteStaticKey?.copyOf()
}
fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? =
noiseService.getAuthenticatedSession(peerID)
fun withAuthenticatedSession(
peerID: String,
expectedSession: AuthenticatedNoiseSession,
action: () -> Boolean
): Boolean = noiseService.withAuthenticatedSession(peerID, expectedSession, action)
/**
* Get current peer ID for a fingerprint (for peer ID rotation)
@ -200,9 +300,9 @@ class EncryptionService(private val context: Context) {
/**
* Initiate a Noise handshake with a peer
*/
fun initiateHandshake(peerID: String): ByteArray? {
fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? {
Log.d(TAG, "🤝 Initiating Noise handshake with $peerID")
return noiseService.initiateHandshake(peerID)
return noiseService.initiateHandshake(peerID, replaceEstablished)
}
/**
@ -212,11 +312,24 @@ class EncryptionService(private val context: Context) {
Log.d(TAG, "🤝 Processing handshake message from $peerID")
return noiseService.processHandshakeMessage(data, peerID)
}
/**
* Process one Noise handshake frame while preserving whether this exact call authenticated a
* new session. Unlike the response-only compatibility API, binding failures are propagated.
*/
@Throws(Exception::class)
open fun processHandshakeMessageWithResult(
data: ByteArray,
peerID: String
): NoiseHandshakeProcessingResult {
Log.d(TAG, "🤝 Processing typed handshake message from $peerID")
return noiseService.processHandshakeMessageWithResult(data, peerID)
}
/**
* Remove a peer session (called when peer disconnects)
*/
fun removePeer(peerID: String) {
open fun removePeer(peerID: String) {
establishedSessions.remove(peerID)
noiseService.removePeer(peerID)
onSessionLost?.invoke(peerID)
@ -321,4 +434,92 @@ class EncryptionService(private val context: Context) {
noiseService.shutdown()
Log.d(TAG, "🔌 EncryptionService shut down")
}
// MARK: - Ed25519 Signature Verification
/**
* Verify Ed25519 signature against data using a public key
*/
open fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
return try {
val publicKey = Ed25519PublicKeyParameters(publicKeyBytes, 0)
val verifier = Ed25519Signer()
verifier.init(false, publicKey)
verifier.update(data, 0, data.size)
val isValid = verifier.verifySignature(signature)
Log.d(TAG, "✅ Ed25519 signature verification: $isValid")
isValid
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to verify Ed25519 signature: ${e.message}")
false
}
}
// MARK: - Private Key Management
/**
* Load existing Ed25519 key pair from preferences or create a new one
*/
private fun loadOrCreateEd25519KeyPair(): AsymmetricCipherKeyPair {
// Migrate legacy plaintext Ed25519 key to encrypted storage if present
migrateOldEd25519KeyIfNeeded()
try {
val storedKey = prefs.getString(ED25519_PRIVATE_KEY_PREF, null)
if (storedKey != null) {
// Load existing key
val privateKeyBytes = Base64.decode(storedKey, Base64.DEFAULT)
val privateKey = Ed25519PrivateKeyParameters(privateKeyBytes, 0)
val publicKey = privateKey.generatePublicKey()
Log.d(TAG, "✅ Loaded existing Ed25519 signing key pair")
return AsymmetricCipherKeyPair(publicKey, privateKey)
}
} catch (e: Exception) {
Log.w(TAG, "⚠️ Failed to load existing Ed25519 key, creating new one: ${e.message}")
}
// Create new key pair
return generateAndSaveEd25519KeyPair()
}
fun generateAndSaveEd25519KeyPair(): AsymmetricCipherKeyPair {
val keyGen = Ed25519KeyPairGenerator()
keyGen.init(Ed25519KeyGenerationParameters(SecureRandom()))
val keyPair = keyGen.generateKeyPair()
// Store private key in preferences
try {
val privateKey = keyPair.private as Ed25519PrivateKeyParameters
val privateKeyBytes = privateKey.encoded
val encodedKey = Base64.encodeToString(privateKeyBytes, Base64.DEFAULT)
prefs.edit { putString(ED25519_PRIVATE_KEY_PREF, encodedKey) }
Log.d(TAG, "✅ Created and stored new Ed25519 signing key pair")
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to store Ed25519 private key: ${e.message}")
}
return keyPair
}
private fun migrateOldEd25519KeyIfNeeded() {
try {
// old existing plain text preference
val oldPrefs = context.getSharedPreferences(OLD_PREFS_NAME, Context.MODE_PRIVATE)
val oldKey = oldPrefs.getString(ED25519_PRIVATE_KEY_PREF, null)
if (oldKey != null && !prefs.contains(ED25519_PRIVATE_KEY_PREF)) {
prefs.edit {
putString(ED25519_PRIVATE_KEY_PREF, oldKey)
}
oldPrefs.edit {
remove(ED25519_PRIVATE_KEY_PREF)
}
Log.d(TAG, "🔁 Migrated Ed25519 key to EncryptedSharedPreferences")
}
} catch (e: Exception) {
Log.w(TAG, "⚠️ Failed to migrate Ed25519 key; generating new identity: ${e.message}")
}
}
}

View File

@ -0,0 +1,33 @@
package com.bitchat.android.favorites
import com.bitchat.android.services.ContactIdentityResolver
data class FavoriteControlMessage(
val isFavorite: Boolean,
val npub: String?
) {
companion object {
private const val FAVORITED = "[FAVORITED]"
private const val UNFAVORITED = "[UNFAVORITED]"
fun parse(content: String): FavoriteControlMessage? {
val trimmed = content.trim()
val isFavorite = when {
trimmed.startsWith(FAVORITED) -> true
trimmed.startsWith(UNFAVORITED) -> false
else -> return null
}
val encodedKey = trimmed.substringAfter(":", "").trim()
val npub = encodedKey
.takeIf { it.isNotEmpty() }
?.let { ContactIdentityResolver.nostrPubkeyHex(it) }
?.let { ContactIdentityResolver.npubFromHex(it) }
return FavoriteControlMessage(isFavorite = isFavorite, npub = npub)
}
fun encode(isFavorite: Boolean, npub: String?): String {
val prefix = if (isFavorite) FAVORITED else UNFAVORITED
return "$prefix:${npub.orEmpty()}"
}
}
}

View File

@ -0,0 +1,409 @@
package com.bitchat.android.favorites
import android.content.Context
import android.util.Log
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.services.ContactIdentityResolver
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.util.*
/**
* Bridging Noise and Nostr favorites
*/
data class FavoriteRelationship(
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes)
val peerNostrPublicKey: String?, // npub bech32 string
val peerNickname: String,
val isFavorite: Boolean, // We favorited them
val theyFavoritedUs: Boolean, // They favorited us
val favoritedAt: Date,
val lastUpdated: Date
) {
val isMutual: Boolean get() = isFavorite && theyFavoritedUs
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FavoriteRelationship
if (!peerNoisePublicKey.contentEquals(other.peerNoisePublicKey)) return false
if (peerNostrPublicKey != other.peerNostrPublicKey) return false
if (peerNickname != other.peerNickname) return false
if (isFavorite != other.isFavorite) return false
if (theyFavoritedUs != other.theyFavoritedUs) return false
return true
}
override fun hashCode(): Int {
var result = peerNoisePublicKey.contentHashCode()
result = 31 * result + (peerNostrPublicKey?.hashCode() ?: 0)
result = 31 * result + peerNickname.hashCode()
result = 31 * result + isFavorite.hashCode()
result = 31 * result + theyFavoritedUs.hashCode()
return result
}
}
internal fun FavoriteRelationship?.withPeerFavoritedUs(
noisePublicKey: ByteArray,
theyFavoritedUs: Boolean,
now: Date = Date()
): FavoriteRelationship {
return this?.copy(
theyFavoritedUs = theyFavoritedUs,
lastUpdated = now
) ?: FavoriteRelationship(
peerNoisePublicKey = noisePublicKey,
peerNostrPublicKey = null,
peerNickname = "Unknown",
isFavorite = false,
theyFavoritedUs = theyFavoritedUs,
favoritedAt = now,
lastUpdated = now
)
}
interface FavoritesChangeListener {
fun onFavoriteChanged(noiseKeyHex: String)
fun onAllCleared()
}
/**
* Manages favorites with NoiseNostr mapping
* Singleton pattern matching iOS implementation.
*/
class FavoritesPersistenceService private constructor(private val context: Context) {
companion object {
private const val TAG = "FavoritesPersistenceService"
private const val FAVORITES_KEY = "favorite_relationships" // noiseHex -> relationship
private const val PEERID_INDEX_KEY = "favorite_peerid_index" // peerID(16-hex) -> npub
@Volatile
private var INSTANCE: FavoritesPersistenceService? = null
val shared: FavoritesPersistenceService
get() = INSTANCE ?: throw IllegalStateException("FavoritesPersistenceService not initialized")
fun initialize(context: Context) {
if (INSTANCE == null) {
synchronized(this) {
if (INSTANCE == null) {
INSTANCE = FavoritesPersistenceService(context.applicationContext)
}
}
}
}
}
private val stateManager = SecureIdentityStateManager(context)
private val gson = Gson()
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseHex -> relationship
private val peerIdIndex = mutableMapOf<String, String>() // peerID (lowercase 16-hex) -> npub
private val listeners = mutableListOf<FavoritesChangeListener>()
init {
loadFavorites()
loadPeerIdIndex()
}
/** Get favorite status for Noise public key */
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
return favorites[keyHex]
}
/** Get favorite status for a mesh peer ID or full Noise public key hex. */
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
val pid = peerID.trim().lowercase()
if (ContactIdentityResolver.isNoiseKeyHex(pid)) {
return favorites[pid]
}
ContactIdentityResolver.fingerprintFromContactConversationId(pid)?.let { fingerprint ->
return favorites.values.firstOrNull { relationship ->
ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey)
.equals(fingerprint, ignoreCase = true)
}
}
if (ContactIdentityResolver.isMeshPeerId(pid)) {
peerIdIndex[pid]?.let { indexedNpub ->
findNoiseKey(indexedNpub)?.let { return getFavoriteStatus(it) }
}
return favorites.values.firstOrNull { relationship ->
ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey) == pid
}
}
return null
}
/** Update Nostr public key for a peer (indexed by Noise key) */
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
?.let { ContactIdentityResolver.npubFromHex(it) }
?: nostrPubkey
val existing = favorites[keyHex]
if (existing != null) {
val updated = existing.copy(
peerNostrPublicKey = normalizedNpub,
lastUpdated = Date()
)
favorites[keyHex] = updated
} else {
val relationship = FavoriteRelationship(
peerNoisePublicKey = noisePublicKey,
peerNostrPublicKey = normalizedNpub,
peerNickname = "Unknown",
isFavorite = false,
theyFavoritedUs = false,
favoritedAt = Date(),
lastUpdated = Date()
)
favorites[keyHex] = relationship
}
saveFavorites()
notifyChanged(keyHex)
Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...")
}
/** Update Nostr pubkey for a specific mesh peerID. */
fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) {
val pid = peerID.trim().lowercase()
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
?.let { ContactIdentityResolver.npubFromHex(it) }
?: nostrPubkey
if (ContactIdentityResolver.isMeshPeerId(pid)) {
peerIdIndex[pid] = normalizedNpub
savePeerIdIndex()
notifyChanged(pid)
Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}")
} else {
Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID")
}
}
/** Resolve Nostr pubkey via current peerID mapping or stored Noise identity. */
fun findNostrPubkeyForPeerID(peerID: String): String? {
val pid = peerID.trim().lowercase()
return peerIdIndex[pid] ?: getFavoriteStatus(pid)?.peerNostrPublicKey
}
/** Resolve mesh peerID for a given Nostr pubkey (npub or hex). */
fun findPeerIDForNostrPubkey(nostrPubkey: String): String? {
val targetHex = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) ?: return null
peerIdIndex.entries.firstOrNull { (_, stored) ->
ContactIdentityResolver.nostrPubkeyHex(stored) == targetHex
}?.let { return it.key }
favorites.values.firstOrNull { relationship ->
relationship.peerNostrPublicKey?.let { ContactIdentityResolver.nostrPubkeyHex(it) } == targetHex
}?.let { relationship ->
return ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey)
}
return null
}
/** Update favorite status */
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val existing = favorites[keyHex]
val updated = if (existing != null) {
existing.copy(
peerNickname = nickname,
isFavorite = isFavorite,
lastUpdated = Date(),
favoritedAt = if (isFavorite && !existing.isFavorite) Date() else existing.favoritedAt
)
} else {
FavoriteRelationship(
peerNoisePublicKey = noisePublicKey,
peerNostrPublicKey = null,
peerNickname = nickname,
isFavorite = isFavorite,
theyFavoritedUs = false,
favoritedAt = Date(),
lastUpdated = Date()
)
}
favorites[keyHex] = updated
saveFavorites()
notifyChanged(keyHex)
Log.d(TAG, "Updated favorite status for $nickname: $isFavorite")
}
/** Update peer favorited-us flag */
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val existing = favorites[keyHex]
val updated = existing.withPeerFavoritedUs(noisePublicKey, theyFavoritedUs)
favorites[keyHex] = updated
saveFavorites()
notifyChanged(keyHex)
Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs")
}
fun getMutualFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isMutual }
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
fun getAllRelationships(): List<FavoriteRelationship> = favorites.values.toList()
fun clearAllFavorites() {
favorites.clear()
saveFavorites()
peerIdIndex.clear()
savePeerIdIndex()
Log.i(TAG, "Cleared all favorites")
notifyAllCleared()
}
/** Find Noise key by Nostr pubkey */
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
val targetHex = ContactIdentityResolver.nostrPubkeyHex(forNostrPubkey) ?: return null
return favorites.values.firstOrNull { rel ->
rel.peerNostrPublicKey?.let { stored -> ContactIdentityResolver.nostrPubkeyHex(stored) } == targetHex
}?.peerNoisePublicKey
}
/** Find Nostr pubkey by Noise key */
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey)
return favorites[keyHex]?.peerNostrPublicKey
}
// MARK: - Persistence
private fun loadFavorites() {
try {
val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY)
if (favoritesJson != null) {
val type = object : TypeToken<Map<String, FavoriteRelationshipData>>() {}.type
val data: Map<String, FavoriteRelationshipData> = gson.fromJson(favoritesJson, type)
favorites.clear()
data.forEach { (key, relationshipData) ->
favorites[key] = relationshipData.toFavoriteRelationship()
}
Log.d(TAG, "Loaded ${favorites.size} favorite relationships")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load favorites: ${e.message}")
}
}
private fun saveFavorites() {
try {
val data = favorites.mapValues { (_, relationship) ->
FavoriteRelationshipData.fromFavoriteRelationship(relationship)
}
val favoritesJson = gson.toJson(data)
stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson)
Log.d(TAG, "Saved ${favorites.size} favorite relationships")
} catch (e: Exception) {
Log.e(TAG, "Failed to save favorites: ${e.message}")
}
}
private fun loadPeerIdIndex() {
try {
val json = stateManager.getSecureValue(PEERID_INDEX_KEY)
if (json != null) {
val type = object : TypeToken<Map<String, String>>() {}.type
val data: Map<String, String> = gson.fromJson(json, type)
peerIdIndex.clear()
data.forEach { (peerID, npub) ->
val normalizedPeerID = peerID.lowercase()
if (ContactIdentityResolver.isMeshPeerId(normalizedPeerID)) {
peerIdIndex[normalizedPeerID] = npub
}
}
Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load peerID index: ${e.message}")
}
}
private fun savePeerIdIndex() {
try {
val json = gson.toJson(peerIdIndex)
stateManager.storeSecureValue(PEERID_INDEX_KEY, json)
Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings")
} catch (e: Exception) {
Log.e(TAG, "Failed to save peerID index: ${e.message}")
}
}
// MARK: - Listeners
fun addListener(listener: FavoritesChangeListener) {
synchronized(listeners) { if (!listeners.contains(listener)) listeners.add(listener) }
}
fun removeListener(listener: FavoritesChangeListener) {
synchronized(listeners) { listeners.remove(listener) }
}
private fun notifyChanged(noiseKeyHex: String) {
runCatching { AppStateStore.canonicalizePrivateChats() }
val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { runCatching { it.onFavoriteChanged(noiseKeyHex) } }
}
private fun notifyAllCleared() {
val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { runCatching { it.onAllCleared() } }
}
}
/** Serializable data for JSON storage */
private data class FavoriteRelationshipData(
val peerNoisePublicKeyHex: String,
val peerNostrPublicKey: String?,
val peerNickname: String,
val isFavorite: Boolean,
val theyFavoritedUs: Boolean,
val favoritedAt: Long,
val lastUpdated: Long
) {
companion object {
fun fromFavoriteRelationship(relationship: FavoriteRelationship): FavoriteRelationshipData {
return FavoriteRelationshipData(
peerNoisePublicKeyHex = ContactIdentityResolver.noiseKeyHex(relationship.peerNoisePublicKey),
peerNostrPublicKey = relationship.peerNostrPublicKey,
peerNickname = relationship.peerNickname,
isFavorite = relationship.isFavorite,
theyFavoritedUs = relationship.theyFavoritedUs,
favoritedAt = relationship.favoritedAt.time,
lastUpdated = relationship.lastUpdated.time
)
}
}
fun toFavoriteRelationship(): FavoriteRelationship {
val noiseKeyBytes = ContactIdentityResolver.bytesFromHex(peerNoisePublicKeyHex) ?: ByteArray(0)
return FavoriteRelationship(
peerNoisePublicKey = noiseKeyBytes,
peerNostrPublicKey = peerNostrPublicKey,
peerNickname = peerNickname,
isFavorite = isFavorite,
theyFavoritedUs = theyFavoritedUs,
favoritedAt = Date(favoritedAt),
lastUpdated = Date(lastUpdated)
)
}
}

View File

@ -0,0 +1,404 @@
package com.bitchat.android.features.file
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.util.Log
import androidx.core.content.FileProvider
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.text.SimpleDateFormat
import java.util.*
object FileUtils {
private const val TAG = "FileUtils"
/**
* Save a file from URI to app's file directory with unique filename
*/
fun saveFileFromUri(
context: Context,
uri: Uri,
originalName: String? = null
): String? {
return try {
val inputStream = context.contentResolver.openInputStream(uri)
if (inputStream == null) {
Log.e(TAG, "❌ Failed to open input stream for URI: $uri")
return null
}
Log.d(TAG, "📂 Opened input stream successfully")
// Determine file extension
val extension = originalName?.substringAfterLast(".") ?: "bin"
val fileName = "file_${System.currentTimeMillis()}.$extension"
// Create incoming dir if needed
val incomingDir = File(context.filesDir, "files/incoming").apply {
if (!exists()) mkdirs()
}
val file = File(incomingDir, fileName)
inputStream.use { input ->
FileOutputStream(file).use { output ->
input.copyTo(output)
}
}
Log.d(TAG, "Saved file to: ${file.absolutePath}")
file.absolutePath
} catch (e: Exception) {
Log.e(TAG, "Failed to save file from URI", e)
null
}
}
/**
* Copy file to app's outgoing directory for sending
*/
fun copyFileForSending(context: Context, uri: Uri, originalName: String? = null): String? {
Log.d(TAG, "🔄 Starting file copy from URI: $uri")
return try {
val inputStream = context.contentResolver.openInputStream(uri)
if (inputStream == null) {
Log.e(TAG, "❌ Failed to open input stream for URI: $uri")
return null
}
Log.d(TAG, "📂 Opened input stream successfully")
// Determine original filename and extension if available
val displayName = originalName ?: run {
try {
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.DISPLAY_NAME)
if (nameIndex >= 0 && cursor.moveToFirst()) cursor.getString(nameIndex) else null
}
} catch (_: Exception) { null }
}
val extension = displayName?.substringAfterLast('.', missingDelimiterValue = "")?.takeIf { it.isNotBlank() }
?: run {
// Try mime type to extension
val mime = try { context.contentResolver.getType(uri) } catch (_: Exception) { null }
android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "bin"
}
// Preserve original filename (without artificial prefixes), ensure uniqueness
val baseName = displayName?.substringBeforeLast('.')?.take(64)?.replace(Regex("[^A-Za-z0-9._-]"), "_")
?: "file"
var fileName = if (extension.isNotBlank()) "$baseName.$extension" else baseName
// Create outgoing dir if needed
val outgoingDir = File(context.filesDir, "files/outgoing").apply {
if (!exists()) mkdirs()
}
var target = File(outgoingDir, fileName)
if (target.exists()) {
var idx = 1
val pureBase = baseName
val dotExt = if (extension.isNotBlank()) ".${extension}" else ""
while (target.exists() && idx < 1000) {
fileName = "$pureBase ($idx)$dotExt"
target = File(outgoingDir, fileName)
idx++
}
}
inputStream.use { input ->
FileOutputStream(target).use { output ->
input.copyTo(output)
}
}
Log.d(TAG, "✅ Successfully copied file for sending: ${target.absolutePath}")
Log.d(TAG, "📊 Final file size: ${target.length()} bytes")
target.absolutePath
} catch (e: Exception) {
Log.e(TAG, "❌ CRITICAL: Failed to copy file for sending", e)
Log.e(TAG, "❌ Source URI: $uri")
Log.e(TAG, "❌ Original name: $originalName")
Log.e(TAG, "❌ Error type: ${e.javaClass.simpleName}")
null
}
}
/**
* Get MIME type for a file based on extension
*/
fun getMimeTypeFromExtension(fileName: String): String {
return when (fileName.substringAfterLast(".", "").lowercase()) {
"pdf" -> "application/pdf"
"doc" -> "application/msword"
"docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
"xls" -> "application/vnd.ms-excel"
"xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
"ppt" -> "application/vnd.ms-powerpoint"
"pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation"
"txt" -> "text/plain"
"json" -> "application/json"
"xml" -> "application/xml"
"csv" -> "text/csv"
"html", "htm" -> "text/html"
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"gif" -> "image/gif"
"bmp" -> "image/bmp"
"webp" -> "image/webp"
"svg" -> "image/svg+xml"
"mp3" -> "audio/mpeg"
"wav" -> "audio/wav"
"m4a" -> "audio/mp4"
"mp4" -> "video/mp4"
"avi" -> "video/x-msvideo"
"mov" -> "video/quicktime"
"zip" -> "application/zip"
"rar" -> "application/vnd.rar"
"7z" -> "application/x-7z-compressed"
else -> "application/octet-stream"
}
}
/**
* Format file size for display
*/
fun formatFileSize(bytes: Long): String {
val units = arrayOf("B", "KB", "MB", "GB")
var size = bytes.toDouble()
var unitIndex = 0
while (size >= 1024 && unitIndex < units.size - 1) {
size /= 1024.0
unitIndex++
}
return "%.1f %s".format(size, units[unitIndex])
}
/**
* Check if file is viewable in system viewer
*/
fun isFileViewable(fileName: String): Boolean {
val extension = fileName.substringAfterLast(".", "").lowercase()
return extension in listOf(
"pdf", "txt", "json", "xml", "html", "htm", "csv",
"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"
)
}
/**
* Save an incoming file packet to app storage and return absolute path.
* Mirrors existing behavior used in MessageHandler (preserves names and folders).
*/
fun saveIncomingFile(
context: Context,
file: com.bitchat.android.model.BitchatFilePacket
): String {
val lowerMime = file.mimeType.lowercase()
val isImage = lowerMime.startsWith("image/")
// FIX: Use cacheDir instead of filesDir to prevent storage exhaustion attacks (Issue #592)
// Files in cacheDir are eligible for automatic system cleanup when space is low
val baseDir = context.cacheDir
val subdir = if (isImage) "images/incoming" else "files/incoming"
val dir = java.io.File(baseDir, subdir).apply { mkdirs() }
fun extFromMime(m: String): String = when (m.lowercase()) {
"image/jpeg", "image/jpg" -> ".jpg"
"image/png" -> ".png"
"image/webp" -> ".webp"
"application/pdf" -> ".pdf"
"text/plain" -> ".txt"
else -> if (isImage) ".jpg" else ".bin"
}
// Prefer transmitted original name; ensure uniqueness to avoid overwrites
val baseName = (file.fileName.takeIf { it.isNotBlank() }
?: (if (isImage) "img" else "file"))
.replace(Regex("[^A-Za-z0-9._-]"), "_")
val ext = extFromMime(lowerMime)
var safeName = if (baseName.contains('.')) baseName else baseName + ext
var idx = 1
while (java.io.File(dir, safeName).exists() && idx < 1000) {
val dot = safeName.lastIndexOf('.')
safeName = if (dot > 0) {
val b = safeName.substring(0, dot)
val e = safeName.substring(dot)
"$b ($idx)$e"
} else {
"$safeName ($idx)"
}
idx++
}
return try {
val out = java.io.File(dir, safeName)
out.outputStream().use { it.write(file.content) }
out.absolutePath
} catch (_: Exception) {
// Fallback to cache dir with uniqueness
try {
var fallback = safeName
var idx2 = 1
while (java.io.File(context.cacheDir, fallback).exists() && idx2 < 1000) {
val dot = fallback.lastIndexOf('.')
fallback = if (dot > 0) {
val b = fallback.substring(0, dot)
val e = fallback.substring(dot)
"$b ($idx2)$e"
} else {
"$fallback ($idx2)"
}
idx2++
}
val out = java.io.File(context.cacheDir, fallback)
out.outputStream().use { it.write(file.content) }
out.absolutePath
} catch (_: Exception) {
val tmp = java.io.File.createTempFile(if (isImage) "img_" else "file_", if (isImage) ".jpg" else ".bin")
tmp.writeBytes(file.content)
tmp.absolutePath
}
}
}
/**
* Classify BitchatMessageType from MIME string used in file messages.
*/
fun messageTypeForMime(mime: String): com.bitchat.android.model.BitchatMessageType {
val lower = mime.lowercase()
return when {
lower.startsWith("image/") -> com.bitchat.android.model.BitchatMessageType.Image
lower.startsWith("audio/") -> com.bitchat.android.model.BitchatMessageType.Audio
else -> com.bitchat.android.model.BitchatMessageType.File
}
}
/**
* Recursively delete all media files (incoming and outgoing)
* Used for Panic Mode cleanup
*/
fun clearAllMedia(context: Context) {
try {
// Clear files dir subdirectories (legacy storage and outgoing)
val filesDir = context.filesDir
val dirsToClear = listOf(
"files/incoming",
"files/outgoing",
"images/incoming",
"images/outgoing",
"voicenotes"
)
dirsToClear.forEach { subDir ->
val dir = File(filesDir, subDir)
if (dir.exists()) {
dir.deleteRecursively()
Log.d(TAG, "Deleted media directory from filesDir: $subDir")
}
}
// Clear cache dir subdirectories (new incoming storage)
// Note: cacheDir.deleteRecursively() below would handle this, but being explicit ensures these
// specific media folders are targeted even if full cache clear fails or is modified later.
val cacheDir = context.cacheDir
val cacheDirsToClear = listOf(
"files/incoming",
"images/incoming"
)
cacheDirsToClear.forEach { subDir ->
val dir = File(cacheDir, subDir)
if (dir.exists()) {
dir.deleteRecursively()
Log.d(TAG, "Deleted media directory from cacheDir: $subDir")
}
}
// Also clear entire cache dir as a catch-all
context.cacheDir.deleteRecursively()
Log.d(TAG, "Cleared entire cache directory")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear media files", e)
}
}
/**
* Delete app-owned media referenced only by the conversation being removed.
*
* Canonical-path checks prevent message content from turning this into an arbitrary-file
* deletion primitive. Shared paths remain intact while any retained message still references
* them.
*/
fun deleteConversationMedia(
context: Context,
deletedMessages: Collection<BitchatMessage>,
retainedMessages: Collection<BitchatMessage>
) {
val mediaTypes = setOf(
BitchatMessageType.Audio,
BitchatMessageType.Image,
BitchatMessageType.File
)
val roots = listOf(context.filesDir, context.cacheDir)
.mapNotNull { runCatching { it.canonicalFile }.getOrNull() }
val retainedPaths = retainedMessages
.asSequence()
.filter { it.type in mediaTypes }
.mapNotNull { message ->
runCatching { File(message.content.trim()).canonicalPath }.getOrNull()
}
.toSet()
deletedMessages
.asSequence()
.filter { it.type in mediaTypes }
.mapNotNull { message ->
runCatching { File(message.content.trim()).canonicalFile }.getOrNull()
}
.distinctBy(File::getPath)
.filter { file ->
file.path !in retainedPaths &&
roots.any { root ->
file.path == root.path ||
file.path.startsWith(root.path + File.separator)
}
}
.forEach { file ->
runCatching {
if (file.isFile && !file.delete()) {
Log.w(TAG, "Unable to delete conversation media")
}
}
}
}
/**
* Deletes paths proven by the conversation database to have no remaining message reference.
* The same canonical-root boundary as explicit conversation deletion prevents arbitrary paths
* from being removed even if persisted metadata is malformed.
*/
fun deleteStoredMediaPaths(context: Context, paths: Collection<String>) {
val roots = listOf(context.filesDir, context.cacheDir)
.mapNotNull { runCatching { it.canonicalFile }.getOrNull() }
paths.asSequence()
.mapNotNull { runCatching { File(it).canonicalFile }.getOrNull() }
.distinctBy(File::getPath)
.filter { file ->
roots.any { root ->
file.path == root.path ||
file.path.startsWith(root.path + File.separator)
}
}
.forEach { file ->
runCatching {
if (file.isFile && !file.delete()) {
Log.w(TAG, "Unable to delete unreferenced conversation media")
}
}
}
}
}

View File

@ -0,0 +1,111 @@
package com.bitchat.android.features.media
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
object ImageUtils {
fun downscaleAndSaveToAppFiles(context: Context, uri: Uri, maxDim: Int = 512, quality: Int = 85): String? {
return try {
val resolver = context.contentResolver
val exifRotation = resolver.openInputStream(uri)?.use { getRotationDegreesFromExif(it) } ?: 0
// Reopen for decode as the previous stream is consumed
val input = resolver.openInputStream(uri) ?: return null
val original = BitmapFactory.decodeStream(input)
input.close()
original ?: return null
val oriented = if (exifRotation != 0) rotateBitmap(original, exifRotation) else original
val w = oriented.width
val h = oriented.height
val scale = (maxOf(w, h).toFloat() / maxDim.toFloat()).coerceAtLeast(1f)
val newW = (w / scale).toInt().coerceAtLeast(1)
val newH = (h / scale).toInt().coerceAtLeast(1)
val scaled = if (scale > 1f) Bitmap.createScaledBitmap(oriented, newW, newH, true) else oriented
val dir = File(context.filesDir, "images/outgoing").apply { mkdirs() }
val outFile = File(dir, "img_${System.currentTimeMillis()}.jpg")
FileOutputStream(outFile).use { fos ->
scaled.compress(Bitmap.CompressFormat.JPEG, quality, fos)
}
try { if (oriented !== original) original.recycle() } catch (_: Exception) {}
try { if (scaled !== oriented) oriented.recycle() } catch (_: Exception) {}
outFile.absolutePath
} catch (e: Exception) {
null
}
}
fun downscalePathAndSaveToAppFiles(context: Context, path: String, maxDim: Int = 512, quality: Int = 85): String? {
return try {
val original = BitmapFactory.decodeFile(path) ?: return null
val exifRotation = getRotationDegreesFromExif(path)
val oriented = if (exifRotation != 0) rotateBitmap(original, exifRotation) else original
val w = oriented.width
val h = oriented.height
val scale = (maxOf(w, h).toFloat() / maxDim.toFloat()).coerceAtLeast(1f)
val newW = (w / scale).toInt().coerceAtLeast(1)
val newH = (h / scale).toInt().coerceAtLeast(1)
val scaled = if (scale > 1f) Bitmap.createScaledBitmap(oriented, newW, newH, true) else oriented
val dir = File(context.filesDir, "images/outgoing").apply { mkdirs() }
val outFile = File(dir, "img_${System.currentTimeMillis()}.jpg")
FileOutputStream(outFile).use { fos ->
scaled.compress(Bitmap.CompressFormat.JPEG, quality, fos)
}
try { if (oriented !== original) original.recycle() } catch (_: Exception) {}
try { if (scaled !== oriented) oriented.recycle() } catch (_: Exception) {}
outFile.absolutePath
} catch (e: Exception) {
null
}
}
fun loadBitmapWithExifOrientation(path: String): Bitmap? {
return try {
val base = BitmapFactory.decodeFile(path) ?: return null
val rotation = getRotationDegreesFromExif(path)
if (rotation != 0) rotateBitmap(base, rotation) else base
} catch (_: Exception) {
null
}
}
private fun rotateBitmap(src: Bitmap, degrees: Int): Bitmap {
return try {
val m = Matrix()
m.postRotate(degrees.toFloat())
Bitmap.createBitmap(src, 0, 0, src.width, src.height, m, true).also {
try { src.recycle() } catch (_: Exception) {}
}
} catch (_: Exception) {
src
}
}
private fun getRotationDegreesFromExif(path: String): Int = try {
val exif = ExifInterface(path)
orientationToDegrees(exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL))
} catch (_: Exception) { 0 }
private fun getRotationDegreesFromExif(stream: InputStream): Int = try {
val exif = ExifInterface(stream)
orientationToDegrees(exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL))
} catch (_: Exception) { 0 }
private fun orientationToDegrees(orientation: Int): Int = when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90
ExifInterface.ORIENTATION_ROTATE_180 -> 180
ExifInterface.ORIENTATION_ROTATE_270 -> 270
ExifInterface.ORIENTATION_TRANSPOSE -> 90
ExifInterface.ORIENTATION_TRANSVERSE -> 270
else -> 0
}
}

View File

@ -0,0 +1,362 @@
package com.bitchat.android.features.voice
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import android.media.MediaRecorder
import android.util.Log
import java.io.File
import java.nio.ByteOrder
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.math.sin
/** Destination selected for one hold gesture. Calls must be safe from a capture thread. */
fun interface LiveVoiceTarget {
fun send(packet: ByteArray)
}
internal data class LiveVoiceCaptureStats(
val queuedPcmFrames: Int,
val encodedFrames: Int,
val dataPackets: Int,
val droppedOversizeFrames: Int,
val outboundPackets: Int,
val deliveredPackets: Int
)
/**
* AudioRecord + MediaCodec capture used only when a live mesh route is available.
*
* Encoded access units are streamed as iOS-compatible burst packets while the same units are
* muxed into an ordinary `.m4a`, which the existing voice-note path sends on release.
*/
internal class LiveVoiceCapture(
private val outputDirectory: File,
private val target: LiveVoiceTarget,
private val burstID: ByteArray = VoiceBurstPacket.makeBurstID(),
/** Debug Mesh Lab uses a deterministic tone so physical tests never capture ambient audio. */
private val syntheticPcm: Boolean = false
) {
companion object {
private const val TAG = "LiveVoiceCapture"
private const val SAMPLE_RATE = 16_000
private const val CHANNEL_COUNT = 1
private const val BIT_RATE = 16_000
private const val SAMPLES_PER_AAC_FRAME = 1_024
private const val MIN_VALID_DURATION_MS = 600L
private const val CODEC_TIMEOUT_US = 10_000L
private const val CODEC_INPUT_DEADLINE_MS = 1_000L
private const val SYNTHETIC_TONE_HZ = 440.0
private const val SYNTHETIC_TONE_AMPLITUDE = 8_000
private const val AAC_FRAME_DURATION_NS = 64_000_000L
private const val OUTBOUND_QUEUE_CAPACITY = 256
private const val OUTBOUND_DRAIN_TIMEOUT_MS = 10_000L
}
private val running = AtomicBoolean(false)
private val amplitude = AtomicInteger(0)
private val queuedPcmFrames = AtomicInteger(0)
private val encodedFrames = AtomicInteger(0)
private val outboundPackets = AtomicInteger(0)
private val deliveredPackets = AtomicInteger(0)
private val outboundQueue = LinkedBlockingQueue<ByteArray>(OUTBOUND_QUEUE_CAPACITY)
private val senderRunning = AtomicBoolean(false)
private val packetizer = VoiceBurstPacketizer(burstID)
private var streamStarted = false
private var startedAtMs = 0L
private var totalSamples = 0L
private var audioRecord: AudioRecord? = null
private var codec: MediaCodec? = null
private var muxer: MediaMuxer? = null
private var muxerTrack = -1
private var muxerStarted = false
private var outputFile: File? = null
private var captureThread: Thread? = null
private var senderThread: Thread? = null
@SuppressLint("MissingPermission")
fun start(): File? {
if (running.get()) return outputFile
return try {
outputDirectory.mkdirs()
val burstHex = VoiceBurstPacket.burstIDHex(burstID)
val file = File(outputDirectory, "voice_$burstHex.m4a")
if (file.exists()) file.delete()
outputFile = file
val format = MediaFormat.createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_AAC,
SAMPLE_RATE,
CHANNEL_COUNT
).apply {
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, SAMPLES_PER_AAC_FRAME * 4)
}
val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
start()
}
codec = encoder
val mediaMuxer = MediaMuxer(file.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
muxer = mediaMuxer
val record = if (syntheticPcm) {
null
} else {
val minBuffer = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
if (minBuffer <= 0) error("AudioRecord buffer unavailable: $minBuffer")
AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
max(minBuffer * 4, SAMPLES_PER_AAC_FRAME * 16)
).also {
if (it.state != AudioRecord.STATE_INITIALIZED) {
it.release()
error("AudioRecord did not initialize")
}
it.startRecording()
if (it.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
it.release()
error("AudioRecord did not start")
}
}
}
audioRecord = record
startedAtMs = System.currentTimeMillis()
senderRunning.set(true)
senderThread = Thread(::senderLoop, "bitchat-ptt-sender").also(Thread::start)
running.set(true)
captureThread = Thread(::captureLoop, "bitchat-ptt-capture").also(Thread::start)
file
} catch (error: Exception) {
Log.w(TAG, "Live capture unavailable; caller will fall back to a voice note: ${error.message}")
releaseResources()
outputFile?.delete()
outputFile = null
null
}
}
fun pollAmplitude(): Int = amplitude.get()
fun stats(): LiveVoiceCaptureStats = LiveVoiceCaptureStats(
queuedPcmFrames = queuedPcmFrames.get(),
encodedFrames = encodedFrames.get(),
dataPackets = packetizer.dataPacketCount,
droppedOversizeFrames = packetizer.droppedFrameCount,
outboundPackets = outboundPackets.get(),
deliveredPackets = deliveredPackets.get()
)
fun stop(canceled: Boolean): File? {
val wasRunning = running.getAndSet(false)
if (wasRunning) {
runCatching { audioRecord?.stop() }
runCatching { captureThread?.join(3_000L) }
}
captureThread = null
val elapsedMs = (System.currentTimeMillis() - startedAtMs).coerceAtLeast(0L)
val durationMs = (encodedFrames.get().toLong() * SAMPLES_PER_AAC_FRAME * 1_000L) / SAMPLE_RATE
val valid = !canceled && elapsedMs >= MIN_VALID_DURATION_MS &&
durationMs >= MIN_VALID_DURATION_MS && encodedFrames.get() > 0 &&
(outputFile?.length() ?: 0L) > 0L
packetizer.flush().forEach(::queueOutbound)
val controlKind = if (valid) {
VoiceBurstPacket.Kind.End(packetizer.dataPacketCount, durationMs.coerceAtMost(0xFFFF_FFFFL))
} else {
VoiceBurstPacket.Kind.Canceled
}
VoiceBurstPacket.create(burstID, packetizer.nextSequence, controlKind)
?.encode()
?.let(::queueOutbound)
senderRunning.set(false)
runCatching { senderThread?.join(OUTBOUND_DRAIN_TIMEOUT_MS) }
if (senderThread?.isAlive == true) {
Log.w(TAG, "Live voice sender did not drain before timeout")
senderThread?.interrupt()
}
senderThread = null
val file = outputFile
outputFile = null
if (!valid) {
file?.delete()
return null
}
return file
}
private fun captureLoop() {
val pcm = ShortArray(SAMPLES_PER_AAC_FRAME)
var nextSyntheticFrameNs = System.nanoTime() + AAC_FRAME_DURATION_NS
try {
while (running.get()) {
val read = if (syntheticPcm) {
val waitNs = nextSyntheticFrameNs - System.nanoTime()
if (waitNs > 0L) {
Thread.sleep(waitNs / 1_000_000L, (waitNs % 1_000_000L).toInt())
}
nextSyntheticFrameNs += AAC_FRAME_DURATION_NS
val firstSample = totalSamples
pcm.indices.forEach { index ->
val phase = 2.0 * Math.PI * SYNTHETIC_TONE_HZ *
(firstSample + index).toDouble() / SAMPLE_RATE.toDouble()
pcm[index] = (sin(phase) * SYNTHETIC_TONE_AMPLITUDE).roundToInt().toShort()
}
pcm.size
} else {
audioRecord?.read(pcm, 0, pcm.size, AudioRecord.READ_BLOCKING) ?: break
}
if (read <= 0) continue
amplitude.set(pcm.take(read).maxOfOrNull { abs(it.toInt()) } ?: 0)
queuePcm(pcm, read, endOfStream = false)
drainEncoder(endOfStream = false)
}
queuePcm(pcm, 0, endOfStream = true)
drainEncoder(endOfStream = true)
} catch (error: Exception) {
Log.w(TAG, "Live capture stopped after codec/audio failure: ${error.message}")
} finally {
releaseResources()
}
}
private fun queuePcm(samples: ShortArray, count: Int, endOfStream: Boolean) {
val encoder = codec ?: return
val deadlineNs = System.nanoTime() + CODEC_INPUT_DEADLINE_MS * 1_000_000L
while (true) {
val index = encoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (index >= 0) {
val input = encoder.getInputBuffer(index)
?: error("AAC encoder returned a null input buffer")
input.clear()
input.order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().put(samples, 0, count)
val presentationUs = (totalSamples * 1_000_000L) / SAMPLE_RATE
totalSamples += count
encoder.queueInputBuffer(
index,
0,
count * 2,
presentationUs,
if (endOfStream) MediaCodec.BUFFER_FLAG_END_OF_STREAM else 0
)
if (!endOfStream) queuedPcmFrames.incrementAndGet()
return
}
// Pull encoded output before retrying so transient codec backpressure cannot discard
// the already-read 64 ms microphone block.
drainEncoder(endOfStream = false)
if (System.nanoTime() >= deadlineNs) {
error("AAC encoder input remained unavailable")
}
}
}
private fun drainEncoder(endOfStream: Boolean) {
val encoder = codec ?: return
val info = MediaCodec.BufferInfo()
var sawEnd = false
var idlePolls = 0
while (!sawEnd && (!endOfStream || idlePolls < 100)) {
when (val index = encoder.dequeueOutputBuffer(info, if (endOfStream) CODEC_TIMEOUT_US else 0L)) {
MediaCodec.INFO_TRY_AGAIN_LATER -> {
idlePolls++
if (!endOfStream) return
}
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
if (muxerStarted) error("AAC output format changed twice")
muxerTrack = muxer?.addTrack(encoder.outputFormat) ?: -1
muxer?.start()
muxerStarted = true
}
else -> if (index >= 0) {
idlePolls = 0
val output = encoder.getOutputBuffer(index)
if (output != null && info.size > 0 && info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
output.position(info.offset)
output.limit(info.offset + info.size)
if (muxerStarted && muxerTrack >= 0) {
muxer?.writeSampleData(muxerTrack, output.duplicate(), info)
}
val accessUnit = ByteArray(info.size)
output.get(accessUnit)
emitEncodedFrame(accessUnit)
}
sawEnd = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
encoder.releaseOutputBuffer(index, false)
}
}
}
}
private fun emitEncodedFrame(frame: ByteArray) {
if (frame.isEmpty()) return
if (!streamStarted) {
streamStarted = true
VoiceBurstPacket.create(
burstID,
0,
VoiceBurstPacket.Kind.Start(VoiceBurstCodec.AAC_LC_16K_MONO)
)?.encode()?.let(::queueOutbound)
}
encodedFrames.incrementAndGet()
packetizer.add(frame).forEach(::queueOutbound)
// At the target bitrate a packet fits one frame; flushing immediately avoids latency.
packetizer.flush().forEach(::queueOutbound)
}
private fun queueOutbound(packet: ByteArray) {
outboundQueue.put(packet.copyOf())
outboundPackets.incrementAndGet()
}
private fun senderLoop() {
try {
while (senderRunning.get() || outboundQueue.isNotEmpty()) {
val packet = outboundQueue.poll(100L, TimeUnit.MILLISECONDS) ?: continue
target.send(packet)
deliveredPackets.incrementAndGet()
}
} catch (error: InterruptedException) {
Thread.currentThread().interrupt()
} catch (error: Exception) {
Log.w(TAG, "Live voice network sender stopped: ${error.message}")
} finally {
senderRunning.set(false)
}
}
private fun releaseResources() {
runCatching { audioRecord?.stop() }
runCatching { audioRecord?.release() }
audioRecord = null
runCatching { codec?.stop() }
runCatching { codec?.release() }
codec = null
if (muxerStarted) runCatching { muxer?.stop() }
runCatching { muxer?.release() }
muxer = null
muxerStarted = false
muxerTrack = -1
}
}

View File

@ -0,0 +1,478 @@
package com.bitchat.android.features.voice
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.services.AppStateStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.util.Date
import java.util.TreeMap
import java.util.UUID
enum class LiveVoiceScope { DIRECT_MESSAGE, PUBLIC_MESH }
sealed interface LiveVoiceEvent {
val peerID: String
val burstID: String
val scope: LiveVoiceScope
data class Started(
override val peerID: String,
override val burstID: String,
override val scope: LiveVoiceScope
) : LiveVoiceEvent
data class Finished(
override val peerID: String,
override val burstID: String,
override val scope: LiveVoiceScope,
val dataPackets: Int,
val frames: Int,
val bytes: Int,
val expectedPackets: Int?,
val missingPackets: Int,
val path: String
) : LiveVoiceEvent
data class Canceled(
override val peerID: String,
override val burstID: String,
override val scope: LiveVoiceScope
) : LiveVoiceEvent
data class Absorbed(
override val peerID: String,
override val burstID: String,
override val scope: LiveVoiceScope,
val finalizedPath: String
) : LiveVoiceEvent
}
/** Shared phone/Wear receiver: bounded assembly, live playback, bubble state and note absorption. */
class LiveVoiceManager private constructor(private val context: Context) {
companion object {
private const val TAG = "LiveVoiceManager"
private const val MAX_CONCURRENT_ASSEMBLIES = 8
private const val MAX_BURST_BYTES = 384 * 1_024
private const val INBOUND_BYTES_PER_SECOND = 6_000
private const val MAX_BUFFERED_PACKETS = 128
private const val GAP_SKIP_MS = 550L
private const val IDLE_TIMEOUT_MS = 3_000L
private const val FINISHED_TTL_MS = 10 * 60 * 1_000L
private const val FINISHED_CAP = 32
// The manager constructor immediately narrows this to context.applicationContext.
@SuppressLint("StaticFieldLeak")
@Volatile private var instance: LiveVoiceManager? = null
fun getInstance(context: Context): LiveVoiceManager = instance ?: synchronized(this) {
instance ?: LiveVoiceManager(context.applicationContext).also { instance = it }
}
fun burstIDFromVoiceFileName(fileName: String): String? {
if (!fileName.startsWith("voice_")) return null
val id = fileName.removePrefix("voice_").take(16)
return id.takeIf { value ->
value.length == 16 && value.all { it.digitToIntOrNull(16) != null }
}?.lowercase()
}
}
private data class AssemblyKey(
val peerID: String,
val scope: LiveVoiceScope,
val burstID: String
)
private class Assembly(
val key: AssemblyKey,
val nickname: String,
val message: BitchatMessage,
var file: File,
val output: FileOutputStream,
val startedAtMs: Long,
val player: PttAudioPlayer?
) {
val buffered = TreeMap<Int, List<ByteArray>>()
var nextSequence = 1
var deliveredFrames = 0
var deliveredPackets = 0
var missingPackets = 0
var receivedBytes = 0
var endTotalPackets: Int? = null
var idleJob: Job? = null
var gapJob: Job? = null
}
private data class FinishedBurst(
val key: AssemblyKey,
val messageID: String,
val nickname: String,
val file: File,
val timestamp: Date,
val expiresAtMs: Long,
val dataPackets: Int,
val frames: Int,
val bytes: Int
)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val assemblies = linkedMapOf<AssemblyKey, Assembly>()
private val finishedBursts = linkedMapOf<AssemblyKey, FinishedBurst>()
private val _liveMessageIDs = MutableStateFlow<Set<String>>(emptySet())
val liveMessageIDs: StateFlow<Set<String>> = _liveMessageIDs.asStateFlow()
private val _activePublicTalker = MutableStateFlow<String?>(null)
val activePublicTalker: StateFlow<String?> = _activePublicTalker.asStateFlow()
private val _events = MutableSharedFlow<LiveVoiceEvent>(extraBufferCapacity = 64)
val events: SharedFlow<LiveVoiceEvent> = _events.asSharedFlow()
@Volatile private var appForeground = false
@Volatile private var visibleScope: LiveVoiceScope? = null
@Volatile private var visiblePeerID: String? = null
private var activePlayer: PttAudioPlayer? = null
init {
liveDirectory().listFiles()
?.filter { it.name.startsWith("voice_live_") }
?.forEach { it.delete() }
}
fun setAppForeground(foreground: Boolean) {
appForeground = foreground
if (!foreground) {
synchronized(this) {
activePlayer?.stop()
activePlayer = null
assemblies.values.forEach { assembly -> assembly.player?.stop() }
}
}
}
fun showPublicMesh() {
visibleScope = LiveVoiceScope.PUBLIC_MESH
visiblePeerID = null
}
fun showDirectMessage(peerID: String) {
visibleScope = LiveVoiceScope.DIRECT_MESSAGE
visiblePeerID = peerID
}
fun clearVisibleConversation() {
visibleScope = null
visiblePeerID = null
}
/** Returns false only when the frame itself violates the live-voice wire/resource contract. */
@Synchronized
fun handleFrame(
peerID: String,
nickname: String,
scope: LiveVoiceScope,
payload: ByteArray,
timestampMs: Long
): Boolean {
val packet = VoiceBurstPacket.decode(payload) ?: return false
if (!LiveVoicePreferences.isEnabled(context)) return true
val burstHex = VoiceBurstPacket.burstIDHex(packet.burstID)
val key = AssemblyKey(peerID, scope, burstHex)
var assembly = assemblies[key]
if (assembly == null) {
if (packet.kind is VoiceBurstPacket.Kind.End || packet.kind == VoiceBurstPacket.Kind.Canceled) {
return true
}
if (assemblies.size >= MAX_CONCURRENT_ASSEMBLIES) return false
assembly = createAssembly(key, nickname, timestampMs) ?: return false
assemblies[key] = assembly
publishLiveState()
_events.tryEmit(LiveVoiceEvent.Started(peerID, burstHex, scope))
}
assembly.receivedBytes += payload.size
val elapsedSeconds = ((System.currentTimeMillis() - assembly.startedAtMs).coerceAtLeast(0L) / 1_000.0) + 2.0
if (
assembly.receivedBytes > MAX_BURST_BYTES ||
assembly.receivedBytes > (INBOUND_BYTES_PER_SECOND * elapsedSeconds).toInt()
) {
Log.w(TAG, "Dropping over-quota live voice burst")
finalizeAssembly(assembly)
return false
}
rescheduleIdle(assembly)
when (val kind = packet.kind) {
is VoiceBurstPacket.Kind.Start -> if (kind.codec != VoiceBurstCodec.AAC_LC_16K_MONO) {
cancelAssembly(assembly)
return false
}
is VoiceBurstPacket.Kind.Frames -> {
if (packet.sequence < assembly.nextSequence || packet.sequence in assembly.buffered) return true
if (assembly.buffered.size >= MAX_BUFFERED_PACKETS) return false
assembly.buffered[packet.sequence] = kind.frames
drainInOrder(assembly)
}
is VoiceBurstPacket.Kind.End -> {
assembly.endTotalPackets = kind.totalDataPackets
drainInOrder(assembly)
finalizeIfComplete(assembly)
}
VoiceBurstPacket.Kind.Canceled -> cancelAssembly(assembly)
}
return true
}
/** Swaps a finalized `voice_<burstID>.m4a` into its existing live row. */
@Synchronized
fun absorbFinalizedVoiceNote(message: BitchatMessage): Boolean {
if (message.type != BitchatMessageType.Audio) return false
val burstID = burstIDFromVoiceFileName(File(message.content).name) ?: return false
val messageScope = if (message.isPrivate) LiveVoiceScope.DIRECT_MESSAGE else LiveVoiceScope.PUBLIC_MESH
val peerID = message.senderPeerID ?: return false
assemblies.entries.firstOrNull {
it.key.peerID == peerID && it.key.scope == messageScope && it.key.burstID == burstID
}?.value?.let(::finalizeAssembly)
pruneFinished()
val entry = finishedBursts.entries.firstOrNull {
it.key.peerID == peerID && it.key.scope == messageScope && it.key.burstID == burstID
} ?: return false
val finished = entry.value
val replacement = message.copy(
id = finished.messageID,
timestamp = finished.timestamp,
sender = finished.nickname,
senderPeerID = peerID,
isPrivate = messageScope == LiveVoiceScope.DIRECT_MESSAGE
)
if (messageScope == LiveVoiceScope.DIRECT_MESSAGE) {
AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID))
} else {
AppStateStore.upsertPublicMessage(replacement)
}
finished.file.delete()
finishedBursts.remove(entry.key)
_events.tryEmit(LiveVoiceEvent.Absorbed(peerID, burstID, messageScope, message.content))
return true
}
@Synchronized
fun reset() {
assemblies.values.toList().forEach(::cancelAssembly)
activePlayer?.stop()
activePlayer = null
finishedBursts.clear()
publishLiveState()
}
private fun createAssembly(key: AssemblyKey, nickname: String, timestampMs: Long): Assembly? {
val file = File(
liveDirectory(),
"voice_live_${key.burstID}_${key.peerID}_${if (key.scope == LiveVoiceScope.DIRECT_MESSAGE) "dm" else "mesh"}.aac"
)
file.parentFile?.mkdirs()
file.delete()
val output = runCatching { FileOutputStream(file) }.getOrNull() ?: return null
val message = BitchatMessage(
id = UUID.randomUUID().toString().uppercase(),
sender = nickname,
content = file.absolutePath,
type = BitchatMessageType.Audio,
timestamp = Date(timestampMs),
isPrivate = key.scope == LiveVoiceScope.DIRECT_MESSAGE,
recipientNickname = AppStateStore.nickname.value.takeIf { key.scope == LiveVoiceScope.DIRECT_MESSAGE },
senderPeerID = key.peerID
)
if (key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
AppStateStore.addPrivateMessage(key.peerID, message, isVisible(key.scope, key.peerID))
} else {
AppStateStore.addPublicMessage(message)
}
val player = if (canAutoplay(key)) {
activePlayer?.stop()
PttAudioPlayer().also { activePlayer = it }
} else null
return Assembly(key, nickname, message, file, output, System.currentTimeMillis(), player)
}
private fun drainInOrder(assembly: Assembly) {
while (true) {
val frames = assembly.buffered.remove(assembly.nextSequence)
if (frames != null) {
frames.forEach { frame ->
runCatching { assembly.output.write(AdtsFramer.frame(frame)) }
}
runCatching { assembly.output.flush() }
assembly.deliveredFrames += frames.size
assembly.deliveredPackets++
assembly.player?.enqueue(frames)
assembly.nextSequence = (assembly.nextSequence + 1) and 0xFFFF
assembly.gapJob?.cancel()
assembly.gapJob = null
continue
}
if (assembly.buffered.isNotEmpty() && assembly.gapJob == null) {
val key = assembly.key
assembly.gapJob = scope.launch {
delay(GAP_SKIP_MS)
synchronized(this@LiveVoiceManager) {
val current = assemblies[key] ?: return@synchronized
current.buffered.firstKey()?.let { skipGap(current, it) }
current.gapJob = null
drainInOrder(current)
finalizeIfComplete(current)
}
}
}
return
}
}
private fun finalizeIfComplete(assembly: Assembly) {
val total = assembly.endTotalPackets ?: return
if (assembly.nextSequence > total) finalizeAssembly(assembly)
}
private fun finalizeAssembly(assembly: Assembly) {
if (assemblies.remove(assembly.key) == null) return
assembly.idleJob?.cancel()
assembly.gapJob?.cancel()
while (assembly.buffered.isNotEmpty()) {
skipGap(assembly, assembly.buffered.firstKey())
drainInOrder(assembly)
if (assembly.gapJob != null) {
assembly.gapJob?.cancel()
assembly.gapJob = null
}
}
runCatching { assembly.output.close() }
assembly.player?.finishAfterDrain()
val missingPackets = assembly.endTotalPackets
?.let { total -> (total - assembly.deliveredPackets).coerceAtLeast(assembly.missingPackets) }
?: assembly.missingPackets
if (assembly.deliveredFrames == 0) {
removeBubble(assembly)
assembly.file.delete()
publishLiveState()
return
}
val fallback = File(
assembly.file.parentFile,
"voice_${assembly.key.burstID}_${assembly.key.peerID}_${if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) "dm" else "mesh"}.aac"
)
fallback.delete()
if (assembly.file.renameTo(fallback)) assembly.file = fallback
val finalizedMessage = assembly.message.copy(content = assembly.file.absolutePath)
if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
AppStateStore.upsertPrivateMessage(
assembly.key.peerID,
finalizedMessage,
isVisible(assembly.key.scope, assembly.key.peerID)
)
} else {
AppStateStore.upsertPublicMessage(finalizedMessage)
}
pruneFinished()
finishedBursts[assembly.key] = FinishedBurst(
key = assembly.key,
messageID = assembly.message.id,
nickname = assembly.nickname,
file = assembly.file,
timestamp = assembly.message.timestamp,
expiresAtMs = System.currentTimeMillis() + FINISHED_TTL_MS,
dataPackets = assembly.deliveredPackets,
frames = assembly.deliveredFrames,
bytes = assembly.receivedBytes
)
_events.tryEmit(
LiveVoiceEvent.Finished(
assembly.key.peerID,
assembly.key.burstID,
assembly.key.scope,
assembly.deliveredPackets,
assembly.deliveredFrames,
assembly.receivedBytes,
assembly.endTotalPackets,
missingPackets,
assembly.file.absolutePath
)
)
publishLiveState()
}
private fun cancelAssembly(assembly: Assembly) {
if (assemblies.remove(assembly.key) == null) return
assembly.idleJob?.cancel()
assembly.gapJob?.cancel()
assembly.player?.stop()
runCatching { assembly.output.close() }
removeBubble(assembly)
assembly.file.delete()
_events.tryEmit(
LiveVoiceEvent.Canceled(assembly.key.peerID, assembly.key.burstID, assembly.key.scope)
)
publishLiveState()
}
private fun skipGap(assembly: Assembly, nextAvailableSequence: Int) {
val distance = (nextAvailableSequence - assembly.nextSequence) and 0xFFFF
if (distance in 1..0x7FFF) assembly.missingPackets += distance
assembly.nextSequence = nextAvailableSequence
}
private fun removeBubble(assembly: Assembly) {
if (assembly.key.scope == LiveVoiceScope.DIRECT_MESSAGE) {
AppStateStore.removePrivateMessage(assembly.message.id)
} else {
AppStateStore.removePublicMessage(assembly.message.id)
}
}
private fun rescheduleIdle(assembly: Assembly) {
assembly.idleJob?.cancel()
val key = assembly.key
assembly.idleJob = scope.launch {
delay(IDLE_TIMEOUT_MS)
synchronized(this@LiveVoiceManager) {
assemblies[key]?.let(::finalizeAssembly)
}
}
}
private fun publishLiveState() {
_liveMessageIDs.value = assemblies.values.mapTo(linkedSetOf()) { it.message.id }
_activePublicTalker.value = assemblies.values
.firstOrNull { it.key.scope == LiveVoiceScope.PUBLIC_MESH }
?.nickname
}
private fun pruneFinished() {
val now = System.currentTimeMillis()
finishedBursts.entries.removeAll { it.value.expiresAtMs <= now }
while (finishedBursts.size >= FINISHED_CAP) {
val oldest = finishedBursts.minByOrNull { it.value.expiresAtMs }?.key ?: break
finishedBursts.remove(oldest)
}
}
private fun canAutoplay(key: AssemblyKey): Boolean =
LiveVoicePreferences.isEnabled(context) && appForeground && isVisible(key.scope, key.peerID)
private fun isVisible(scope: LiveVoiceScope, peerID: String): Boolean =
visibleScope == scope && (scope == LiveVoiceScope.PUBLIC_MESH || visiblePeerID == peerID)
private fun liveDirectory(): File = File(context.cacheDir, "files/incoming").apply { mkdirs() }
}

View File

@ -0,0 +1,22 @@
package com.bitchat.android.features.voice
import android.content.Context
/** One preference gates live PTT sending and playback; finalized voice notes remain available. */
object LiveVoicePreferences {
private const val PREFERENCES = "bitchat_settings"
private const val ENABLED = "ptt.liveVoiceEnabled"
fun isEnabled(context: Context): Boolean =
context.applicationContext
.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.getBoolean(ENABLED, true)
fun setEnabled(context: Context, enabled: Boolean) {
context.applicationContext
.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
.edit()
.putBoolean(ENABLED, enabled)
.apply()
}
}

View File

@ -0,0 +1,156 @@
package com.bitchat.android.features.voice
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.media.MediaCodec
import android.media.MediaFormat
import android.util.Log
import java.nio.ByteBuffer
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/** Small jitter-buffered AAC player for foreground live bursts. */
internal class PttAudioPlayer {
companion object {
private const val TAG = "PttAudioPlayer"
private const val SAMPLE_RATE = 16_000
private const val FRAME_DURATION_US = 64_000L
private const val JITTER_FRAMES = 6
private const val JITTER_DEADLINE_MS = 500L
private const val CODEC_TIMEOUT_US = 10_000L
}
private val frames = LinkedBlockingQueue<ByteArray>(128)
private val stopped = AtomicBoolean(false)
private val finishing = AtomicBoolean(false)
private val startedAt = System.currentTimeMillis()
private val worker = Thread(::playbackLoop, "bitchat-ptt-playback").also(Thread::start)
fun enqueue(accessUnits: List<ByteArray>) {
if (stopped.get()) return
accessUnits.forEach { frames.offer(it.copyOf()) }
}
fun finishAfterDrain() {
finishing.set(true)
}
fun stop() {
stopped.set(true)
worker.interrupt()
}
private fun playbackLoop() {
var decoder: MediaCodec? = null
var track: AudioTrack? = null
try {
while (
!stopped.get() && frames.size < JITTER_FRAMES &&
System.currentTimeMillis() - startedAt < JITTER_DEADLINE_MS &&
!finishing.get()
) {
Thread.sleep(10L)
}
if (stopped.get() || (frames.isEmpty() && finishing.get())) return
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, SAMPLE_RATE, 1).apply {
setInteger(MediaFormat.KEY_AAC_PROFILE, 2)
// AudioSpecificConfig: AAC-LC, 16 kHz (index 8), mono.
setByteBuffer("csd-0", ByteBuffer.wrap(byteArrayOf(0x14, 0x08)))
}
decoder = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
configure(format, null, null, 0)
start()
}
val minBuffer = AudioTrack.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT
).coerceAtLeast(4_096)
track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.build()
)
.setTransferMode(AudioTrack.MODE_STREAM)
.setBufferSizeInBytes(minBuffer)
.build()
track.play()
val info = MediaCodec.BufferInfo()
var presentationUs = 0L
var inputEnded = false
var outputEnded = false
while (!stopped.get() && !outputEnded) {
if (!inputEnded) {
val frame = frames.poll(25L, TimeUnit.MILLISECONDS)
if (frame != null) {
val index = decoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (index >= 0) {
decoder.getInputBuffer(index)?.apply {
clear()
put(frame)
}
decoder.queueInputBuffer(index, 0, frame.size, presentationUs, 0)
presentationUs += FRAME_DURATION_US
} else {
frames.offer(frame)
}
} else if (finishing.get()) {
val index = decoder.dequeueInputBuffer(CODEC_TIMEOUT_US)
if (index >= 0) {
decoder.queueInputBuffer(
index,
0,
0,
presentationUs,
MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputEnded = true
}
}
}
when (val index = decoder.dequeueOutputBuffer(info, CODEC_TIMEOUT_US)) {
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED,
MediaCodec.INFO_TRY_AGAIN_LATER -> Unit
else -> if (index >= 0) {
decoder.getOutputBuffer(index)?.let { output ->
if (info.size > 0) {
output.position(info.offset)
output.limit(info.offset + info.size)
val pcm = ByteArray(info.size)
output.get(pcm)
track.write(pcm, 0, pcm.size, AudioTrack.WRITE_BLOCKING)
}
}
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
decoder.releaseOutputBuffer(index, false)
}
}
}
} catch (error: InterruptedException) {
Thread.currentThread().interrupt()
} catch (error: Exception) {
Log.w(TAG, "Live playback stopped: ${error.message}")
} finally {
stopped.set(true)
runCatching { track?.stop() }
runCatching { track?.release() }
runCatching { decoder?.stop() }
runCatching { decoder?.release() }
}
}
}

View File

@ -0,0 +1,220 @@
package com.bitchat.android.features.voice
import java.io.ByteArrayOutputStream
import java.security.SecureRandom
/** iOS-compatible codec identifier carried by a live push-to-talk START packet. */
enum class VoiceBurstCodec(val value: UByte) {
AAC_LC_16K_MONO(0x01u);
companion object {
fun fromValue(value: UByte): VoiceBurstCodec? = entries.firstOrNull { it.value == value }
}
}
/**
* One live push-to-talk packet.
*
* Wire format (shared with iOS):
* `[burstID: 8][seq: UInt16 BE][flags: UInt8][payload...]`.
*/
class VoiceBurstPacket private constructor(
val burstID: ByteArray,
val sequence: Int,
val kind: Kind
) {
sealed interface Kind {
data class Start(val codec: VoiceBurstCodec) : Kind
class Frames(val frames: List<ByteArray>) : Kind {
override fun equals(other: Any?): Boolean =
other is Frames && frames.size == other.frames.size &&
frames.indices.all { frames[it].contentEquals(other.frames[it]) }
override fun hashCode(): Int = frames.fold(1) { acc, frame -> 31 * acc + frame.contentHashCode() }
}
data class End(val totalDataPackets: Int, val durationMs: Long) : Kind
data object Canceled : Kind
}
fun encode(): ByteArray {
val output = ByteArrayOutputStream(HEADER_SIZE + 16)
output.write(burstID)
output.write((sequence ushr 8) and 0xFF)
output.write(sequence and 0xFF)
when (val packetKind = kind) {
is Kind.Start -> {
output.write(FLAG_START)
output.write(packetKind.codec.value.toInt())
}
is Kind.Frames -> {
output.write(0)
packetKind.frames.forEach { frame ->
output.write((frame.size ushr 8) and 0xFF)
output.write(frame.size and 0xFF)
output.write(frame)
}
}
is Kind.End -> {
output.write(FLAG_END)
output.write((packetKind.totalDataPackets ushr 8) and 0xFF)
output.write(packetKind.totalDataPackets and 0xFF)
output.write(((packetKind.durationMs ushr 24) and 0xFF).toInt())
output.write(((packetKind.durationMs ushr 16) and 0xFF).toInt())
output.write(((packetKind.durationMs ushr 8) and 0xFF).toInt())
output.write((packetKind.durationMs and 0xFF).toInt())
}
Kind.Canceled -> output.write(FLAG_CANCELED)
}
return output.toByteArray()
}
companion object {
const val BURST_ID_SIZE = 8
const val HEADER_SIZE = BURST_ID_SIZE + 2 + 1
const val MAX_FRAMES_PER_PACKET = 8
const val MAX_CONTENT_BYTES = 210
private const val FLAG_START = 0x01
private const val FLAG_END = 0x02
private const val FLAG_CANCELED = 0x04
private val random = SecureRandom()
fun create(burstID: ByteArray, sequence: Int, kind: Kind): VoiceBurstPacket? {
if (burstID.size != BURST_ID_SIZE || sequence !in 0..0xFFFF) return null
when (kind) {
is Kind.Frames -> if (
kind.frames.isEmpty() ||
kind.frames.size > MAX_FRAMES_PER_PACKET ||
kind.frames.any { it.isEmpty() || it.size > 0xFFFF }
) return null
is Kind.End -> if (
kind.totalDataPackets !in 0..0xFFFF ||
kind.durationMs !in 0..0xFFFF_FFFFL
) return null
else -> Unit
}
return VoiceBurstPacket(burstID.copyOf(), sequence, kind)
}
fun decode(data: ByteArray): VoiceBurstPacket? {
if (data.size < HEADER_SIZE) return null
val burstID = data.copyOfRange(0, BURST_ID_SIZE)
val sequence = ((data[BURST_ID_SIZE].toInt() and 0xFF) shl 8) or
(data[BURST_ID_SIZE + 1].toInt() and 0xFF)
val flags = data[BURST_ID_SIZE + 2].toInt() and 0xFF
val offset = HEADER_SIZE
val kind: Kind = when (flags) {
FLAG_START -> {
if (offset >= data.size) return null
val codec = VoiceBurstCodec.fromValue(data[offset].toUByte()) ?: return null
Kind.Start(codec)
}
FLAG_END -> {
if (data.size - offset < 6) return null
val total = ((data[offset].toInt() and 0xFF) shl 8) or
(data[offset + 1].toInt() and 0xFF)
var duration = 0L
repeat(4) { index ->
duration = (duration shl 8) or (data[offset + 2 + index].toLong() and 0xFF)
}
Kind.End(total, duration)
}
FLAG_CANCELED -> Kind.Canceled
0 -> {
val frames = mutableListOf<ByteArray>()
var cursor = offset
while (cursor < data.size) {
if (data.size - cursor < 2 || frames.size >= MAX_FRAMES_PER_PACKET) return null
val length = ((data[cursor].toInt() and 0xFF) shl 8) or
(data[cursor + 1].toInt() and 0xFF)
cursor += 2
if (length <= 0 || data.size - cursor < length) {
return null
}
frames += data.copyOfRange(cursor, cursor + length)
cursor += length
}
if (frames.isEmpty()) return null
Kind.Frames(frames)
}
else -> return null
}
return create(burstID, sequence, kind)
}
fun makeBurstID(): ByteArray = ByteArray(BURST_ID_SIZE).also(random::nextBytes)
fun burstIDHex(burstID: ByteArray): String =
burstID.joinToString("") { byte -> "%02x".format(byte.toInt() and 0xFF) }
}
}
/** Greedy packetizer constrained so one Noise-wrapped frame stays out of fragmentation. */
class VoiceBurstPacketizer(
val burstID: ByteArray,
private val budget: Int = VoiceBurstPacket.MAX_CONTENT_BYTES
) {
private val pendingFrames = mutableListOf<ByteArray>()
private var pendingSize = 0
var nextSequence: Int = 1
private set
var dataPacketCount: Int = 0
private set
var droppedFrameCount: Int = 0
private set
fun add(frame: ByteArray): List<ByteArray> {
val frameCost = 2 + frame.size
if (VoiceBurstPacket.HEADER_SIZE + frameCost > budget) {
droppedFrameCount++
return emptyList()
}
val output = mutableListOf<ByteArray>()
if (
pendingFrames.isNotEmpty() &&
(VoiceBurstPacket.HEADER_SIZE + pendingSize + frameCost > budget ||
pendingFrames.size >= VoiceBurstPacket.MAX_FRAMES_PER_PACKET)
) {
output += flush()
}
pendingFrames += frame.copyOf()
pendingSize += frameCost
return output
}
fun flush(): List<ByteArray> {
if (pendingFrames.isEmpty()) return emptyList()
val packet = VoiceBurstPacket.create(
burstID,
nextSequence,
VoiceBurstPacket.Kind.Frames(pendingFrames.map(ByteArray::copyOf))
) ?: run {
pendingFrames.clear()
pendingSize = 0
return emptyList()
}
pendingFrames.clear()
pendingSize = 0
nextSequence = (nextSequence + 1) and 0xFFFF
dataPacketCount = (dataPacketCount + 1).coerceAtMost(0xFFFF)
return listOf(packet.encode())
}
}
/** Adds a seven-byte ADTS header to an ADTS-less AAC-LC/16 kHz/mono access unit. */
object AdtsFramer {
fun frame(payload: ByteArray): ByteArray {
val frameLength = payload.size + 7
require(frameLength <= 0x1FFF) { "AAC frame is too large for ADTS" }
return ByteArray(frameLength).also { output ->
output[0] = 0xFF.toByte()
output[1] = 0xF1.toByte()
output[2] = 0x60.toByte() // AAC-LC, 16 kHz frequency index, mono channel config high bit
output[3] = (0x40 or ((frameLength ushr 11) and 0x03)).toByte()
output[4] = ((frameLength ushr 3) and 0xFF).toByte()
output[5] = (((frameLength and 0x07) shl 5) or 0x1F).toByte()
output[6] = 0xFC.toByte()
payload.copyInto(output, destinationOffset = 7)
}
}
}

View File

@ -0,0 +1,109 @@
package com.bitchat.android.features.voice
import android.content.Context
import android.media.MediaRecorder
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Simple MediaRecorder wrapper that records to M4A (AAC) for wide compatibility.
* The resulting file has MIME audio/mp4.
*/
class VoiceRecorder(
private val context: Context,
private val liveTarget: LiveVoiceTarget? = null
) {
companion object { private const val TAG = "VoiceRecorder" }
private var recorder: MediaRecorder? = null
private var liveCapture: LiveVoiceCapture? = null
private val _amplitude = MutableStateFlow(0)
val amplitude: StateFlow<Int> = _amplitude.asStateFlow()
private var outFile: File? = null
val isLive: Boolean
get() = liveCapture != null
fun start(): File? {
stop() // ensure previous session closed
if (liveTarget != null) {
val directory = File(context.filesDir, "voicenotes/outgoing")
val capture = LiveVoiceCapture(directory, liveTarget)
val liveFile = capture.start()
if (liveFile != null) {
liveCapture = capture
outFile = liveFile
return liveFile
}
}
return startClassic()
}
private fun startClassic(): File? {
return try {
val dir = File(context.filesDir, "voicenotes/outgoing").apply { mkdirs() }
val name = "voice_" + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + ".m4a"
val file = File(dir, name)
val rec = MediaRecorder()
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
rec.setAudioChannels(1)
// Target: 16 kHz AAC @ 20 kbps ≈ 2.5 KB/sec
// Lower sample rate and bitrate for compact, speech-optimized recordings
rec.setAudioSamplingRate(16000)
rec.setAudioEncodingBitRate(20_000)
rec.setOutputFile(file.absolutePath)
rec.prepare()
rec.start()
recorder = rec
outFile = file
file
} catch (e: Exception) {
Log.e(TAG, "Failed to start recording: ${e.message}")
null
}
}
fun pollAmplitude(): Int {
return try {
val amp = liveCapture?.pollAmplitude() ?: recorder?.maxAmplitude ?: 0
_amplitude.value = amp
amp
} catch (_: Exception) { 0 }
}
fun stop(canceled: Boolean = false): File? {
liveCapture?.let { capture ->
val file = capture.stop(canceled)
liveCapture = null
outFile = null
return file
}
try {
recorder?.apply {
try { stop() } catch (_: Exception) {}
try { reset() } catch (_: Exception) {}
try { release() } catch (_: Exception) {}
}
} catch (_: Exception) {}
val f = outFile
recorder = null
outFile = null
if (canceled) {
f?.delete()
return null
}
return f
}
}

View File

@ -0,0 +1,42 @@
package com.bitchat.android.features.voice
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.unit.dp
import kotlin.math.min
@Composable
fun CyberpunkVisualizer(amplitude: Int, color: Color, modifier: Modifier = Modifier) {
val norm = min(1f, amplitude / 20_000f)
val heightFrac by animateFloatAsState(
targetValue = 0.1f + 0.9f * norm,
animationSpec = tween(120, easing = LinearEasing), label = "amp"
)
Canvas(
modifier = modifier
.fillMaxWidth()
.height(48.dp)
) {
val w = size.width
val h = size.height
val barCount = 24
val gap = 6f
val bw = (w - gap * (barCount - 1)) / barCount
for (i in 0 until barCount) {
val phase = (i.toFloat() / barCount)
val barH = (0.2f + heightFrac * (0.8f * (0.5f + 0.5f * kotlin.math.sin(phase * Math.PI * 2).toFloat()))) * h
val x = i * (bw + gap)
val y = (h - barH) / 2f
drawRect(color.copy(alpha = 0.85f), topLeft = androidx.compose.ui.geometry.Offset(x, y), size = androidx.compose.ui.geometry.Size(bw, barH))
}
}
}

View File

@ -0,0 +1,174 @@
package com.bitchat.android.features.voice
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.abs
import kotlin.math.ln
import kotlin.math.max
import kotlin.math.min
object VoiceWaveformCache {
private val map = ConcurrentHashMap<String, FloatArray>()
fun put(path: String, samples: FloatArray) { map[path] = samples }
fun get(path: String): FloatArray? = map[path]
}
fun normalizeAmplitudeSample(amp: Int): Float {
val a = max(0, amp)
val norm = ln(1.0 + a.toDouble()) / ln(1.0 + 32768.0)
return norm.toFloat().coerceIn(0f, 1f)
}
fun resampleWave(values: FloatArray, target: Int): FloatArray {
if (values.isEmpty() || target <= 0) return FloatArray(target) { 0f }
if (values.size == target) return values
val out = FloatArray(target)
val step = (values.size - 1).toFloat() / (target - 1).toFloat()
var x = 0f
for (i in 0 until target) {
val idx = x.toInt()
val frac = x - idx
val a = values[idx]
val b = values[min(values.size - 1, idx + 1)]
out[i] = (a + (b - a) * frac).coerceIn(0f, 1f)
x += step
}
return out
}
object AudioWaveformExtractor {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun extractAsync(path: String, sampleCount: Int = 120, onComplete: (FloatArray?) -> Unit) {
scope.launch {
onComplete(runCatching { extract(path, sampleCount) }.getOrNull())
}
}
private fun extract(path: String, sampleCount: Int): FloatArray? {
val extractor = MediaExtractor()
extractor.setDataSource(path)
val trackIndex = (0 until extractor.trackCount).firstOrNull { idx ->
val fmt = extractor.getTrackFormat(idx)
val mime = fmt.getString(MediaFormat.KEY_MIME) ?: ""
mime.startsWith("audio/")
} ?: return null
extractor.selectTrack(trackIndex)
val format = extractor.getTrackFormat(trackIndex)
val mime = format.getString(MediaFormat.KEY_MIME) ?: return null
val codec = MediaCodec.createDecoderByType(mime)
codec.configure(format, null, null, 0)
codec.start()
val durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0L
val desiredBins = sampleCount.coerceAtLeast(32)
val bins = FloatArray(desiredBins) { 0f }
val counts = IntArray(desiredBins) { 0 }
val inBuffers = codec.inputBuffers
val outInfo = MediaCodec.BufferInfo()
var sawEOS = false
while (!sawEOS) {
// Queue input
val inIndex = codec.dequeueInputBuffer(10_000)
if (inIndex >= 0) {
val buffer = codec.getInputBuffer(inIndex) ?: inBuffers[inIndex]
val sampleSize = extractor.readSampleData(buffer, 0)
if (sampleSize < 0) {
codec.queueInputBuffer(inIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
} else {
val presentationTimeUs = extractor.sampleTime
codec.queueInputBuffer(inIndex, 0, sampleSize, presentationTimeUs, 0)
extractor.advance()
}
}
// Dequeue output
var outIndex = codec.dequeueOutputBuffer(outInfo, 10_000)
while (outIndex >= 0) {
val outBuf = codec.getOutputBuffer(outIndex)
if (outBuf != null && outInfo.size > 0) {
outBuf.order(ByteOrder.LITTLE_ENDIAN)
val shortCount = outInfo.size / 2
val shorts = ShortArray(shortCount)
outBuf.asShortBuffer().get(shorts)
// Map this buffer to bins using timestamp range
val startUs = outInfo.presentationTimeUs
val endUs = startUs + bufferDurationUs(format, outInfo.size)
val startBin = binForTime(startUs, durationUs, desiredBins)
val endBin = binForTime(endUs, durationUs, desiredBins).coerceAtMost(desiredBins - 1)
var idx = 0
for (bin in startBin..endBin) {
// aggregate portion of buffer to this bin
val window = shorts.size / max(1, (endBin - startBin + 1))
val begin = idx
val finish = min(shorts.size, idx + window)
var acc = 0.0
var cnt = 0
for (i in begin until finish) {
acc += abs(shorts[i].toInt())
cnt += 1
}
val avg = if (cnt > 0) (acc / cnt) else 0.0
val norm = (avg / 32768.0).coerceIn(0.0, 1.0).toFloat()
bins[bin] = max(bins[bin], norm)
counts[bin] += 1
idx += window
}
}
codec.releaseOutputBuffer(outIndex, false)
outIndex = codec.dequeueOutputBuffer(outInfo, 0)
}
if (outInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
sawEOS = true
}
}
codec.stop()
codec.release()
extractor.release()
// Smooth + normalize
var maxVal = 0f
for (i in bins.indices) {
if (counts[i] == 0) continue
maxVal = max(maxVal, bins[i])
}
if (maxVal <= 0f) maxVal = 1f
for (i in bins.indices) {
bins[i] = (bins[i] / maxVal).coerceIn(0f, 1f)
}
return bins
}
private fun bufferDurationUs(format: MediaFormat, bytes: Int): Long {
return try {
val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
val channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
val samples = bytes / 2 / max(1, channels)
(samples * 1_000_000L) / max(1, sampleRate)
} catch (e: Exception) {
0L
}
}
private fun binForTime(presentationUs: Long, durationUs: Long, bins: Int): Int {
if (durationUs <= 0L) return 0
val frac = presentationUs.toDouble() / durationUs.toDouble()
return (frac * bins).toInt().coerceIn(0, bins - 1)
}
}

View File

@ -0,0 +1,96 @@
package com.bitchat.android.geohash
import android.content.Context
import android.location.Address
import android.location.Geocoder
import android.os.Build
import android.util.Log
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.Locale
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class AndroidGeocoderProvider(context: Context) : GeocoderProvider {
private val geocoder = Geocoder(context, Locale.getDefault())
private val TAG = "AndroidGeocoderProvider"
override suspend fun getFromLocation(
latitude: Double,
longitude: Double,
maxResults: Int,
liveLocationToken: Long?
): List<Address> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
suspendCancellableCoroutine { cont ->
try {
val startRequest = {
geocoder.getFromLocation(
latitude,
longitude,
maxResults,
object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: MutableList<Address>) {
if (cont.isActive) {
val result = if (liveLocationToken == null ||
LiveLocationPrivacyGate.accepts(liveLocationToken)
) {
addresses
} else {
emptyList()
}
cont.resume(result)
}
}
override fun onError(errorMessage: String?) {
if (cont.isActive) {
Log.e(TAG, "Geocode error")
cont.resume(emptyList())
}
}
}
)
}
val started = if (liveLocationToken == null) {
startRequest()
true
} else {
LiveLocationPrivacyGate.runIfAllowed(
liveLocationToken,
startRequest
)
}
if (!started && cont.isActive) cont.resume(emptyList())
} catch (e: Exception) {
if (cont.isActive) cont.resumeWithException(e)
}
}
} else {
@Suppress("DEPRECATION")
try {
if (liveLocationToken != null &&
!LiveLocationPrivacyGate.accepts(liveLocationToken)
) return emptyList()
// This legacy API blocks and cannot be cancelled. Never hold the privacy
// gate's read lock across the call: revocation must remain immediate.
val addresses = geocoder.getFromLocation(
latitude,
longitude,
maxResults
) ?: emptyList()
if (liveLocationToken == null ||
LiveLocationPrivacyGate.accepts(liveLocationToken)
) {
addresses
} else {
emptyList()
}
} catch (e: Exception) {
Log.e(TAG, "Geocode failed")
emptyList()
}
}
}
}

View File

@ -0,0 +1,162 @@
package com.bitchat.android.geohash
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.os.Looper
import android.util.Log
import androidx.core.app.ActivityCompat
import com.google.android.gms.location.*
import com.google.android.gms.tasks.CancellationTokenSource
internal class FusedLocationProvider(private val context: Context) : LocationProvider {
companion object {
private const val TAG = "FusedLocationProvider"
}
private val fusedLocationClient: FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context)
// Map to keep track of callbacks to remove them later
private val activeCallbacks = mutableMapOf<(Location) -> Unit, LocationCallback>()
private val activeCurrentLocationRequests = mutableSetOf<CancellationTokenSource>()
private fun hasLocationPermission(): Boolean {
return LiveLocationPrivacyGate.isEnabled &&
(ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
)
}
@SuppressLint("MissingPermission")
override fun getLastKnownLocation(callback: (Location?) -> Unit) {
if (!hasLocationPermission()) {
callback(null)
return
}
try {
fusedLocationClient.lastLocation
.addOnSuccessListener { location ->
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
}
.addOnFailureListener { e ->
Log.e(TAG, "Error getting last-known fused location")
callback(null)
}
} catch (e: Exception) {
Log.e(TAG, "Exception getting last-known fused location")
callback(null)
}
}
@SuppressLint("MissingPermission")
override fun requestFreshLocation(callback: (Location?) -> Unit) {
if (!hasLocationPermission()) {
callback(null)
return
}
try {
val request = CurrentLocationRequest.Builder()
.setPriority(Priority.PRIORITY_HIGH_ACCURACY)
.setDurationMillis(30000)
.build()
val cancellation = CancellationTokenSource()
synchronized(activeCurrentLocationRequests) {
activeCurrentLocationRequests.add(cancellation)
}
fusedLocationClient.getCurrentLocation(request, cancellation.token)
.addOnSuccessListener { location ->
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
}
.addOnFailureListener { e ->
Log.e(TAG, "Error getting fresh fused location")
callback(null)
}
.addOnCompleteListener {
synchronized(activeCurrentLocationRequests) {
activeCurrentLocationRequests.remove(cancellation)
}
}
} catch (e: Exception) {
Log.e(TAG, "Exception getting fresh fused location")
callback(null)
}
}
@SuppressLint("MissingPermission")
override fun requestLocationUpdates(
intervalMs: Long,
minDistanceMeters: Float,
callback: (Location) -> Unit
) {
if (!hasLocationPermission()) return
try {
val request = LocationRequest.Builder(intervalMs)
.setMinUpdateDistanceMeters(minDistanceMeters)
.setPriority(Priority.PRIORITY_HIGH_ACCURACY)
.build()
val locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
if (LiveLocationPrivacyGate.isEnabled) {
result.lastLocation?.let { callback(it) }
}
}
}
synchronized(activeCallbacks) {
activeCallbacks[callback] = locationCallback
}
fusedLocationClient.requestLocationUpdates(
request,
locationCallback,
Looper.getMainLooper()
)
Log.d(TAG, "Registered fused updates")
} catch (e: Exception) {
Log.e(TAG, "Error requesting fused updates")
}
}
override fun removeLocationUpdates(callback: (Location) -> Unit) {
try {
val locationCallback = synchronized(activeCallbacks) {
activeCallbacks.remove(callback)
}
if (locationCallback != null) {
fusedLocationClient.removeLocationUpdates(locationCallback)
Log.d(TAG, "Removed fused updates")
}
} catch (e: Exception) {
Log.e(TAG, "Error removing fused updates")
}
}
override fun cancel() {
try {
synchronized(activeCallbacks) {
for ((_, locationCallback) in activeCallbacks) {
fusedLocationClient.removeLocationUpdates(locationCallback)
}
activeCallbacks.clear()
}
synchronized(activeCurrentLocationRequests) {
activeCurrentLocationRequests.forEach { it.cancel() }
activeCurrentLocationRequests.clear()
}
Log.d(TAG, "Cancelled all fused updates")
} catch (e: Exception) {
Log.e(TAG, "Error cancelling fused provider")
}
}
}

View File

@ -0,0 +1,19 @@
package com.bitchat.android.geohash
import android.content.Context
import android.location.Geocoder
/**
* Factory to provide the best available geocoder.
*/
object GeocoderFactory {
fun get(context: Context): GeocoderProvider {
// If Google Play Services Geocoder is present, use it.
// Otherwise, fall back to OpenStreetMap.
return if (Geocoder.isPresent()) {
AndroidGeocoderProvider(context)
} else {
OpenStreetMapGeocoderProvider()
}
}
}

View File

@ -0,0 +1,18 @@
package com.bitchat.android.geohash
import android.location.Address
/**
* Interface for reverse geocoding providers.
*/
interface GeocoderProvider {
/**
* Get a list of Address objects from latitude and longitude.
*/
suspend fun getFromLocation(
latitude: Double,
longitude: Double,
maxResults: Int,
liveLocationToken: Long? = null
): List<Address>
}

View File

@ -0,0 +1,150 @@
package com.bitchat.android.geohash
/**
* Lightweight Geohash encoder used for Location Channels.
* Encodes latitude/longitude to base32 geohash with a fixed precision.
*
* Port of iOS implementation for 100% compatibility
*/
object Geohash {
private val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz".toCharArray()
private val charToValue: Map<Char, Int> = base32Chars.withIndex().associate { it.value to it.index }
data class Bounds(val latMin: Double, val latMax: Double, val lonMin: Double, val lonMax: Double)
/**
* Encodes the provided coordinates into a geohash string.
* @param latitude Latitude in degrees (-90...90)
* @param longitude Longitude in degrees (-180...180)
* @param precision Number of geohash characters (2-12 typical). Values <= 0 return an empty string.
* @return Base32 geohash string of length `precision`.
*/
fun encode(latitude: Double, longitude: Double, precision: Int): String {
if (precision <= 0) return ""
var latInterval = -90.0 to 90.0
var lonInterval = -180.0 to 180.0
var isEven = true
var bit = 0
var ch = 0
val geohash = StringBuilder()
val lat = latitude.coerceIn(-90.0, 90.0)
val lon = longitude.coerceIn(-180.0, 180.0)
while (geohash.length < precision) {
if (isEven) {
val mid = (lonInterval.first + lonInterval.second) / 2
if (lon >= mid) {
ch = ch or (1 shl (4 - bit))
lonInterval = mid to lonInterval.second
} else {
lonInterval = lonInterval.first to mid
}
} else {
val mid = (latInterval.first + latInterval.second) / 2
if (lat >= mid) {
ch = ch or (1 shl (4 - bit))
latInterval = mid to latInterval.second
} else {
latInterval = latInterval.first to mid
}
}
isEven = !isEven
if (bit < 4) {
bit += 1
} else {
geohash.append(base32Chars[ch])
bit = 0
ch = 0
}
}
return geohash.toString()
}
/**
* Decodes a geohash string to the center latitude/longitude of its cell.
* @return Pair(latitude, longitude)
*/
fun decodeToCenter(geohash: String): Pair<Double, Double> {
val b = decodeToBounds(geohash)
val latCenter = (b.latMin + b.latMax) / 2
val lonCenter = (b.lonMin + b.lonMax) / 2
return latCenter to lonCenter
}
/**
* Decodes a geohash string to bounding box (lat/lon min/max).
*/
fun decodeToBounds(geohash: String): Bounds {
if (geohash.isEmpty()) return Bounds(0.0, 0.0, 0.0, 0.0)
var latInterval = -90.0 to 90.0
var lonInterval = -180.0 to 180.0
var isEven = true
geohash.lowercase().forEach { ch ->
val cd = charToValue[ch] ?: return Bounds(0.0, 0.0, 0.0, 0.0)
for (mask in intArrayOf(16, 8, 4, 2, 1)) {
if (isEven) {
val mid = (lonInterval.first + lonInterval.second) / 2
if ((cd and mask) != 0) {
lonInterval = mid to lonInterval.second
} else {
lonInterval = lonInterval.first to mid
}
} else {
val mid = (latInterval.first + latInterval.second) / 2
if ((cd and mask) != 0) {
latInterval = mid to latInterval.second
} else {
latInterval = latInterval.first to mid
}
}
isEven = !isEven
}
}
return Bounds(
latMin = minOf(latInterval.first, latInterval.second),
latMax = maxOf(latInterval.first, latInterval.second),
lonMin = minOf(lonInterval.first, lonInterval.second),
lonMax = maxOf(lonInterval.first, lonInterval.second)
)
}
/**
* Returns the 8 neighboring geohash cells at the same precision as the input.
* Neighbors include N, NE, E, SE, S, SW, W, NW, even when crossing parent cell boundaries.
*/
fun neighborsSamePrecision(geohash: String): Set<String> {
if (geohash.isEmpty()) return emptySet()
val p = geohash.length
val b = decodeToBounds(geohash)
val dLat = b.latMax - b.latMin
val dLon = b.lonMax - b.lonMin
fun wrapLon(lon: Double): Double {
var x = lon
while (x > 180.0) x -= 360.0
while (x < -180.0) x += 360.0
return x
}
val neighbors = mutableSetOf<String>()
for (dy in -1..1) {
for (dx in -1..1) {
if (dx == 0 && dy == 0) continue // skip center
val centerLat = (b.latMin + b.latMax) / 2 + dy * dLat
val rawLonCenter = (b.lonMin + b.lonMax) / 2 + dx * dLon
val centerLon = wrapLon(rawLonCenter)
val enc = encode(centerLat.coerceIn(-90.0, 90.0), centerLon, p)
if (enc.isNotEmpty() && enc != geohash) neighbors.add(enc)
}
}
return neighbors
}
}

View File

@ -0,0 +1,247 @@
package com.bitchat.android.geohash
import android.content.Context
import android.location.Geocoder
import android.location.Location
import android.location.LocationManager
import android.util.Log
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Locale
/**
* Stores a user-maintained list of bookmarked geohash channels.
* - Persistence: SharedPreferences (JSON string array)
* - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
*/
class GeohashBookmarksStore private constructor(private val context: Context) {
companion object {
private const val TAG = "GeohashBookmarksStore"
private const val STORE_KEY = "locationChannel.bookmarks"
private const val NAMES_STORE_KEY = "locationChannel.bookmarkNames"
@Volatile private var INSTANCE: GeohashBookmarksStore? = null
fun getInstance(context: Context): GeohashBookmarksStore {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: GeohashBookmarksStore(context.applicationContext).also { INSTANCE = it }
}
}
private val allowedChars = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
fun normalize(raw: String): String {
return raw.trim().lowercase(Locale.US)
.replace("#", "")
.filter { allowedChars.contains(it) }
}
}
private val gson = Gson()
private val prefs = context.getSharedPreferences("geohash_prefs", Context.MODE_PRIVATE)
private val membership = mutableSetOf<String>()
private val _bookmarks = MutableStateFlow<List<String>>(emptyList())
val bookmarks: StateFlow<List<String>> = _bookmarks.asStateFlow()
private val _bookmarkNames = MutableStateFlow<Map<String, String>>(emptyMap())
val bookmarkNames: StateFlow<Map<String, String>> = _bookmarkNames.asStateFlow()
// For throttling / preventing duplicate geocode lookups
private val resolving = mutableSetOf<String>()
init { load() }
fun isBookmarked(geohash: String): Boolean = membership.contains(normalize(geohash))
fun toggle(geohash: String) {
val gh = normalize(geohash)
if (membership.contains(gh)) remove(gh) else add(gh)
}
fun add(geohash: String) {
val gh = normalize(geohash)
if (gh.isEmpty() || membership.contains(gh)) return
membership.add(gh)
val updated = listOf(gh) + (_bookmarks.value)
_bookmarks.value = updated
persist(updated)
// Resolve friendly name asynchronously
resolveNameIfNeeded(gh)
}
fun remove(geohash: String) {
val gh = normalize(geohash)
if (!membership.contains(gh)) return
membership.remove(gh)
val updated = (_bookmarks.value).filterNot { it == gh }
_bookmarks.value = updated
// Remove stored name to avoid stale cache growth
val names = _bookmarkNames.value.toMutableMap()
if (names.remove(gh) != null) {
_bookmarkNames.value = names
persistNames(names)
}
persist(updated)
}
// MARK: - Persistence
private fun load() {
try {
val arrJson = prefs.getString(STORE_KEY, null)
if (!arrJson.isNullOrEmpty()) {
val listType = object : TypeToken<List<String>>() {}.type
val arr = gson.fromJson<List<String>>(arrJson, listType)
val seen = mutableSetOf<String>()
val ordered = mutableListOf<String>()
arr.forEach { raw ->
val gh = normalize(raw)
if (gh.isNotEmpty() && !seen.contains(gh)) {
seen.add(gh)
ordered.add(gh)
}
}
membership.clear(); membership.addAll(seen)
_bookmarks.value = ordered
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load bookmarks")
}
try {
val namesJson = prefs.getString(NAMES_STORE_KEY, null)
if (!namesJson.isNullOrEmpty()) {
val mapType = object : TypeToken<Map<String, String>>() {}.type
val dict = gson.fromJson<Map<String, String>>(namesJson, mapType)
_bookmarkNames.value = dict
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load bookmark names")
}
}
private fun persist() {
try {
val json = gson.toJson(_bookmarks.value)
prefs.edit().putString(STORE_KEY, json).apply()
} catch (_: Exception) {}
}
private fun persistNames() {
try {
val json = gson.toJson(_bookmarkNames.value)
prefs.edit().putString(NAMES_STORE_KEY, json).apply()
} catch (_: Exception) {}
}
// MARK: - Destructive Reset
fun clearAll() {
try {
membership.clear()
_bookmarks.value = emptyList()
_bookmarkNames.value = emptyMap()
prefs.edit()
.remove(STORE_KEY)
.remove(NAMES_STORE_KEY)
.apply()
// Clear any in-flight resolutions to avoid repopulating
resolving.clear()
Log.i(TAG, "Cleared all geohash bookmarks and names")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear geohash bookmarks")
}
}
// MARK: - Friendly Name Resolution
fun resolveNameIfNeeded(geohash: String) {
val gh = normalize(geohash)
if (gh.isEmpty()) return
if (_bookmarkNames.value?.containsKey(gh) == true) return
if (resolving.contains(gh)) return
resolving.add(gh)
CoroutineScope(Dispatchers.IO).launch {
try {
val geocoderProvider = GeocoderFactory.get(context)
val name: String? = if (gh.length <= 2) {
// Composite admin name from multiple points
val b = Geohash.decodeToBounds(gh)
val points = listOf(
Location(LocationManager.GPS_PROVIDER).apply { latitude = (b.latMin + b.latMax) / 2; longitude = (b.lonMin + b.lonMax) / 2 },
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMin; longitude = b.lonMin },
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMin; longitude = b.lonMax },
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMax; longitude = b.lonMin },
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMax; longitude = b.lonMax }
)
val admins = linkedSetOf<String>()
for (loc in points) {
try {
val list = geocoderProvider.getFromLocation(loc.latitude, loc.longitude, 1)
val a = list.firstOrNull()
val admin = a?.adminArea?.takeIf { !it.isNullOrEmpty() }
val country = a?.countryName?.takeIf { !it.isNullOrEmpty() }
if (admin != null) admins.add(admin)
else if (country != null) admins.add(country)
} catch (_: Exception) {}
if (admins.size >= 2) break
}
when (admins.size) {
0 -> null
1 -> admins.first()
else -> admins.elementAt(0) + " and " + admins.elementAt(1)
}
} else {
val center = Geohash.decodeToCenter(gh)
val list = geocoderProvider.getFromLocation(center.first, center.second, 1)
val a = list.firstOrNull()
pickNameForLength(gh.length, a)
}
if (!name.isNullOrEmpty()) {
val current = _bookmarkNames.value.toMutableMap()
current[gh] = name
_bookmarkNames.value = current
persistNames(current)
}
} catch (e: Exception) {
Log.w(TAG, "Bookmark name resolution failed")
} finally {
resolving.remove(gh)
}
}
}
private fun pickNameForLength(len: Int, address: android.location.Address?): String? {
if (address == null) return null
return when (len) {
in 0..2 -> address.adminArea ?: address.countryName
in 3..4 -> address.adminArea ?: address.subAdminArea ?: address.countryName
5 -> address.locality ?: address.subAdminArea ?: address.adminArea
in 6..7 -> address.subLocality ?: address.locality ?: address.adminArea
else -> address.subLocality ?: address.locality ?: address.adminArea ?: address.countryName
}
}
private fun persist(list: List<String>) {
try {
val json = gson.toJson(list)
prefs.edit().putString(STORE_KEY, json).apply()
} catch (_: Exception) {}
}
private fun persistNames(map: Map<String, String>) {
try {
val json = gson.toJson(map)
prefs.edit().putString(NAMES_STORE_KEY, json).apply()
} catch (_: Exception) {}
}
}

View File

@ -0,0 +1,24 @@
package com.bitchat.android.geohash
internal object GeohashNostrPrivacyPolicy {
fun livePresenceTargets(
availableChannels: Collection<GeohashChannel>,
liveLocationEnabled: Boolean,
): Set<String> {
if (!liveLocationEnabled) return emptySet()
return availableChannels
.asSequence()
.filter { it.level.precision <= GeohashChannelLevel.CITY.precision }
.map { it.geohash }
.toSet()
}
fun samplingTargets(
liveLocationGeohashes: Collection<String>,
userSelectedGeohashes: Collection<String>,
liveLocationEnabled: Boolean,
): Set<String> = buildSet {
addAll(userSelectedGeohashes)
if (liveLocationEnabled) addAll(liveLocationGeohashes)
}
}

View File

@ -0,0 +1,119 @@
package com.bitchat.android.geohash
import java.util.concurrent.CopyOnWriteArraySet
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Process-wide, fail-closed consent gate for accessing live device location.
*
* A generation token prevents callbacks that were started under an older consent
* state from being accepted after location access is disabled or re-enabled.
*/
internal class LiveLocationAccessPolicy(
initialEnabled: Boolean = DEFAULT_LIVE_LOCATION_ENABLED,
) {
private val accessLock = ReentrantReadWriteLock()
private val generation = AtomicLong(0L)
private val _enabled = MutableStateFlow(initialEnabled)
private var accessAvailable = initialEnabled
val enabled: StateFlow<Boolean> = _enabled.asStateFlow()
val isEnabled: Boolean
get() = _enabled.value
fun update(enabled: Boolean) {
accessLock.write {
generation.incrementAndGet()
_enabled.value = enabled
accessAvailable = enabled
}
}
fun invalidate() {
accessLock.write {
generation.incrementAndGet()
accessAvailable = false
}
}
fun resumeAccess() {
accessLock.write {
if (_enabled.value && !accessAvailable) {
generation.incrementAndGet()
accessAvailable = true
}
}
}
fun captureToken(): Long? =
accessLock.read {
val capturedGeneration = generation.get()
capturedGeneration.takeIf {
_enabled.value &&
accessAvailable &&
generation.get() == capturedGeneration
}
}
fun accepts(token: Long): Boolean =
accessLock.read {
_enabled.value && accessAvailable && generation.get() == token
}
fun runIfAllowed(token: Long, action: () -> Unit): Boolean =
accessLock.read {
if (!_enabled.value || !accessAvailable || generation.get() != token) {
false
} else {
action()
true
}
}
}
internal const val DEFAULT_LIVE_LOCATION_ENABLED = false
internal object LiveLocationPrivacyGate {
private val policy = LiveLocationAccessPolicy()
private val revocationListeners = CopyOnWriteArraySet<() -> Unit>()
val enabled: StateFlow<Boolean> = policy.enabled
val isEnabled: Boolean
get() = policy.isEnabled
fun update(enabled: Boolean) {
policy.update(enabled)
notifyRevoked()
}
fun invalidate() {
policy.invalidate()
notifyRevoked()
}
fun captureToken(): Long? = policy.captureToken()
fun resumeAccess() = policy.resumeAccess()
fun accepts(token: Long): Boolean = policy.accepts(token)
fun runIfAllowed(token: Long, action: () -> Unit): Boolean =
policy.runIfAllowed(token, action)
fun addRevocationListener(listener: () -> Unit) {
revocationListeners.add(listener)
}
fun removeRevocationListener(listener: () -> Unit) {
revocationListeners.remove(listener)
}
private fun notifyRevoked() {
revocationListeners.forEach { listener ->
runCatching(listener)
}
}
}

View File

@ -0,0 +1,84 @@
package com.bitchat.android.geohash
/**
* Levels of location channels mapped to geohash precisions.
* Direct port from iOS implementation for 100% compatibility
*/
enum class GeohashChannelLevel(val precision: Int, val displayName: String) {
BUILDING(8, "Building"), // iOS: precision 8 for building-level (used for Location Notes)
BLOCK(7, "Block"),
NEIGHBORHOOD(6, "Neighborhood"),
CITY(5, "City"),
PROVINCE(4, "Province"),
REGION(2, "REGION");
companion object {
fun allCases(): List<GeohashChannelLevel> = values().toList()
}
}
/**
* A computed geohash channel option.
* Direct port from iOS implementation
*/
data class GeohashChannel(
val level: GeohashChannelLevel,
val geohash: String
) {
val id: String get() = "${level.name}-$geohash"
val displayName: String get() = "${level.displayName}$geohash"
}
/**
* Identifier for current public chat channel (mesh or a location geohash).
* Direct port from iOS implementation
*/
sealed class ChannelID {
object Mesh : ChannelID()
data class Location(val channel: GeohashChannel) : ChannelID() {
companion object {
fun fromPersisted(levelName: String, geohash: String): Location? {
return try {
val level = GeohashChannelLevel.valueOf(levelName)
Location(GeohashChannel(level, geohash))
} catch (_: IllegalArgumentException) {
null
}
}
}
}
/**
* Human readable name for UI.
*/
val displayName: String
get() = when (this) {
is Mesh -> "Mesh"
is Location -> channel.displayName
}
/**
* Nostr tag value for scoping (geohash), if applicable.
*/
val nostrGeohashTag: String?
get() = when (this) {
is Mesh -> null
is Location -> channel.geohash
}
override fun equals(other: Any?): Boolean {
return when {
this is Mesh && other is Mesh -> true
this is Location && other is Location -> this.channel == other.channel
else -> false
}
}
override fun hashCode(): Int {
return when (this) {
is Mesh -> "mesh".hashCode()
is Location -> channel.hashCode()
}
}
}

View File

@ -0,0 +1,694 @@
package com.bitchat.android.geohash
import android.Manifest
import android.content.Context
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager
import android.util.Log
import androidx.core.app.ActivityCompat
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.bitchat.android.nostr.NostrIdentityBridge
import kotlinx.coroutines.*
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
/**
* Manages location permissions, one-shot location retrieval, and computing geohash channels.
* Direct port from iOS LocationChannelManager for 100% compatibility
*/
class LocationChannelManager private constructor(private val context: Context) {
companion object {
private const val TAG = "LocationChannelManager"
@Volatile
private var INSTANCE: LocationChannelManager? = null
fun getInstance(context: Context): LocationChannelManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: LocationChannelManager(context.applicationContext).also { INSTANCE = it }
}
}
}
// State enum matching iOS
enum class PermissionState {
DENIED,
AUTHORIZED
}
enum class LocationSelectionSource {
NEARBY,
MANUAL
}
private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private val locationProvider: LocationProvider
private val geocoderProvider: GeocoderProvider = GeocoderFactory.get(context)
private var geocodingJob: Job? = null
private val gson = Gson()
private var dataManager: com.bitchat.android.ui.DataManager? = null
private var selectedLocationSource: LocationSelectionSource? = null
private var activeLocationUpdateCallback: ((Location) -> Unit)? = null
private fun checkSystemLocationEnabled(): Boolean {
return try {
locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
} catch (_: Exception) {
false
}
}
private val locationStateReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: Context?, intent: android.content.Intent?) {
if (intent?.action == LocationManager.PROVIDERS_CHANGED_ACTION) {
val isEnabled = checkSystemLocationEnabled()
Log.d(TAG, "System location state changed: $isEnabled")
_systemLocationEnabled.value = isEnabled
if (!isEnabled) {
clearLiveLocationState()
}
}
}
}
// Published state for UI bindings (matching iOS @Published properties)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _permissionState = MutableStateFlow(PermissionState.DENIED)
val permissionState: StateFlow<PermissionState> = _permissionState
private val _availableChannels = MutableStateFlow<List<GeohashChannel>>(emptyList())
val availableChannels: StateFlow<List<GeohashChannel>> = _availableChannels
private val _selectedChannel = MutableStateFlow<ChannelID>(ChannelID.Mesh)
val selectedChannel: StateFlow<ChannelID> = _selectedChannel
private val _teleported = MutableStateFlow(false)
val teleported: StateFlow<Boolean> = _teleported
private val _locationNames = MutableStateFlow<Map<GeohashChannelLevel, String>>(emptyMap())
val locationNames: StateFlow<Map<GeohashChannelLevel, String>> = _locationNames
private val _isLoadingLocation = MutableStateFlow(false)
val isLoadingLocation: StateFlow<Boolean> = _isLoadingLocation
val locationServicesEnabled: StateFlow<Boolean> = LiveLocationPrivacyGate.enabled
private val _systemLocationEnabled = MutableStateFlow(checkSystemLocationEnabled())
val systemLocationEnabled: StateFlow<Boolean> = _systemLocationEnabled
val effectiveLocationEnabled: StateFlow<Boolean> = combine(
locationServicesEnabled,
systemLocationEnabled
) { appToggle, systemToggle ->
appToggle && systemToggle
}.stateIn(
scope,
SharingStarted.Eagerly,
false
)
init {
// Choose the best location provider
val availability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)
locationProvider = if (availability == ConnectionResult.SUCCESS) {
Log.i(TAG, "Using FusedLocationProvider (Google Play Services)")
FusedLocationProvider(context)
} else {
Log.i(TAG, "Using SystemLocationProvider (Native LocationManager)")
SystemLocationProvider(context)
}
LiveLocationPrivacyGate.addRevocationListener(::cancelLiveLocationWork)
// Initialize DataManager and load persisted settings
dataManager = com.bitchat.android.ui.DataManager(context)
loadLocationServicesState()
syncPermissionState()
if (!_systemLocationEnabled.value) clearLiveLocationState()
loadPersistedChannelSelection()
// Register for system location changes
context.registerReceiver(locationStateReceiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION))
}
// MARK: - Public API (matching iOS interface)
/**
* Enable location channels (request permission if needed)
* UNIFIED: Only requests location if location services are enabled by user
*/
fun enableLocationChannels() {
if (!LiveLocationPrivacyGate.isEnabled || !_systemLocationEnabled.value) {
Log.w(TAG, "Location services disabled (app or system) - not requesting location")
return
}
if (syncPermissionState() == PermissionState.AUTHORIZED) {
requestOneShotLocation()
}
}
/**
* Refresh available channels from current location
*/
fun refreshChannels() {
if (syncPermissionState() == PermissionState.AUTHORIZED && isLocationServicesEnabled()) {
requestOneShotLocation()
}
}
/**
* Begin real-time location updates while a selector UI is visible
* Uses requestLocationUpdates for continuous updates, plus a one-shot to prime state immediately
*/
fun beginLiveRefresh(interval: Long = 5000L) {
if (syncPermissionState() != PermissionState.AUTHORIZED) {
Log.w(TAG, "Cannot start live refresh - permission not authorized")
return
}
if (!isLocationServicesEnabled()) {
Log.w(TAG, "Cannot start live refresh - location services disabled")
return
}
endLiveRefresh()
LiveLocationPrivacyGate.resumeAccess()
val token = LiveLocationPrivacyGate.captureToken() ?: return
val callback: (Location) -> Unit = { location ->
if (canUseLiveLocation(token)) {
onLocationUpdated(location, token)
}
}
activeLocationUpdateCallback = callback
// Register for continuous updates from available provider
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.requestLocationUpdates(
intervalMs = interval,
minDistanceMeters = 5f,
callback = callback
)
}
if (!started) {
activeLocationUpdateCallback = null
return
}
// Prime state immediately with last known / current location
requestOneShotLocation()
}
/**
* Stop periodic refreshes when selector UI is dismissed
*/
fun endLiveRefresh() {
activeLocationUpdateCallback?.let(locationProvider::removeLocationUpdates)
activeLocationUpdateCallback = null
}
/**
* Generic selection is intentionally treated as manual. GPS-derived selections must use
* [selectNearby] so their provenance can be revoked when live location is disabled.
*/
fun select(channel: ChannelID) {
when (channel) {
ChannelID.Mesh -> selectInternal(ChannelID.Mesh, source = null, teleported = false)
is ChannelID.Location -> selectManual(channel.channel)
}
}
fun selectNearby(channel: GeohashChannel): Boolean {
val isCurrentNearbyChannel = _availableChannels.value.contains(channel)
if (!isCurrentNearbyChannel || !isLocationServicesEnabled() ||
syncPermissionState() != PermissionState.AUTHORIZED
) {
Log.w(TAG, "Blocked nearby channel selection without live-location access")
return false
}
selectInternal(
ChannelID.Location(channel),
source = LocationSelectionSource.NEARBY,
teleported = false
)
return true
}
fun selectManual(channel: GeohashChannel, teleported: Boolean = true) {
selectInternal(
ChannelID.Location(channel),
source = LocationSelectionSource.MANUAL,
teleported = teleported || !LiveLocationPrivacyGate.isEnabled
)
}
/**
* Enable location services (user-controlled toggle)
*/
fun enableLocationServices() {
if (!LiveLocationPrivacyGate.isEnabled) {
LiveLocationPrivacyGate.update(true)
saveLocationServicesState(true)
}
// If we have permission and system location is on, start location operations
if (syncPermissionState() == PermissionState.AUTHORIZED && systemLocationEnabled.value) {
requestOneShotLocation()
}
}
/**
* Disable location services (user-controlled toggle)
*/
fun disableLocationServices() {
LiveLocationPrivacyGate.update(false)
saveLocationServicesState(false)
clearLiveLocationState(invalidateAccess = false)
}
/**
* Check if location services are enabled by the user
*/
/**
* Check if both the app toggle and system location are enabled
*/
fun isLocationServicesEnabled(): Boolean {
return LiveLocationPrivacyGate.isEnabled && _systemLocationEnabled.value
}
fun canUseSelectedLocationChannel(channel: GeohashChannel): Boolean {
if (_selectedChannel.value != ChannelID.Location(channel)) return false
return selectedLocationSource == LocationSelectionSource.MANUAL ||
LiveLocationPrivacyGate.captureToken() != null
}
fun isSelectedChannelLiveDerived(channel: GeohashChannel): Boolean =
_selectedChannel.value == ChannelID.Location(channel) &&
selectedLocationSource == LocationSelectionSource.NEARBY
fun liveLocationTokenForSelectedChannel(channel: GeohashChannel): Long? {
if (!isSelectedChannelLiveDerived(channel)) return null
return LiveLocationPrivacyGate.captureToken()
}
private fun selectInternal(
channel: ChannelID,
source: LocationSelectionSource?,
teleported: Boolean
) {
selectedLocationSource = source
_teleported.value = when (channel) {
ChannelID.Mesh -> false
is ChannelID.Location -> teleported
}
_selectedChannel.value = channel
saveChannelSelection(channel, source)
}
/**
* Revokes all GPS-derived in-memory state and pending work. This deliberately does not
* change the persisted soft setting when Android's hard permission or system provider is
* temporarily unavailable.
*/
private fun clearLiveLocationState(invalidateAccess: Boolean = true) {
if (invalidateAccess) LiveLocationPrivacyGate.invalidate()
cancelLiveLocationWork()
_isLoadingLocation.value = false
NostrIdentityBridge.clearGeohashIdentityCache(
_availableChannels.value.map { it.geohash }
)
_availableChannels.value = emptyList()
_locationNames.value = emptyMap()
when {
_selectedChannel.value is ChannelID.Location &&
selectedLocationSource == LocationSelectionSource.NEARBY -> {
selectInternal(ChannelID.Mesh, source = null, teleported = false)
}
_selectedChannel.value is ChannelID.Location -> {
// Manual channels remain usable for teleports, bookmarks, and DMs. Never use
// a previously retained GPS fix to classify them while live access is off.
selectedLocationSource = LocationSelectionSource.MANUAL
_teleported.value = true
saveChannelSelection(_selectedChannel.value, selectedLocationSource)
}
}
}
private fun cancelLiveLocationWork() {
locationProvider.cancel()
activeLocationUpdateCallback = null
geocodingJob?.cancel()
geocodingJob = null
}
// MARK: - Location Operations
private fun requestOneShotLocation() {
if (!isLocationServicesEnabled() ||
syncPermissionState() != PermissionState.AUTHORIZED
) {
Log.w(TAG, "No location permission for one-shot request")
return
}
LiveLocationPrivacyGate.resumeAccess()
val token = LiveLocationPrivacyGate.captureToken() ?: return
_isLoadingLocation.value = true
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.getLastKnownLocation { cached ->
if (!canUseLiveLocation(token)) return@getLastKnownLocation
if (cached != null) {
onLocationUpdated(cached, token)
} else {
LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.requestFreshLocation { fresh ->
if (!canUseLiveLocation(token)) return@requestFreshLocation
if (fresh != null) {
onLocationUpdated(fresh, token)
} else {
Log.w(TAG, "Failed to get fresh location")
_isLoadingLocation.value = false
}
}
}
}
}
}
if (!started) _isLoadingLocation.value = false
}
private fun onLocationUpdated(location: Location, token: Long) {
LiveLocationPrivacyGate.runIfAllowed(token) {
if (!_systemLocationEnabled.value || !hasRuntimeLocationPermission()) return@runIfAllowed
_isLoadingLocation.value = false
computeChannels(location, token)
reverseGeocodeIfNeeded(location, token)
}
}
// MARK: - Helpers
private fun hasRuntimeLocationPermission(): Boolean {
return ActivityCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
fun syncPermissionState(): PermissionState {
val newState = if (hasRuntimeLocationPermission()) {
PermissionState.AUTHORIZED
} else {
PermissionState.DENIED
}
if (_permissionState.value != newState) {
_permissionState.value = newState
}
if (newState == PermissionState.DENIED) {
clearLiveLocationState()
}
return newState
}
private fun canUseLiveLocation(token: Long): Boolean {
return LiveLocationPrivacyGate.accepts(token) &&
_systemLocationEnabled.value &&
hasRuntimeLocationPermission()
}
private fun computeChannels(location: Location, token: Long) {
if (!canUseLiveLocation(token)) return
val levels = GeohashChannelLevel.allCases()
val result = mutableListOf<GeohashChannel>()
for (level in levels) {
val geohash = Geohash.encode(
latitude = location.latitude,
longitude = location.longitude,
precision = level.precision
)
result.add(GeohashChannel(level = level, geohash = geohash))
}
if (!canUseLiveLocation(token)) return
_availableChannels.value = result
val selectedChannelValue = _selectedChannel.value
if (selectedChannelValue is ChannelID.Location &&
selectedLocationSource == LocationSelectionSource.NEARBY
) {
val currentGeohash = Geohash.encode(
latitude = location.latitude,
longitude = location.longitude,
precision = selectedChannelValue.channel.level.precision
)
_teleported.value = currentGeohash != selectedChannelValue.channel.geohash
} else if (selectedChannelValue is ChannelID.Mesh) {
_teleported.value = false
}
}
private fun reverseGeocodeIfNeeded(location: Location, token: Long) {
if (!canUseLiveLocation(token)) return
geocodingJob?.cancel()
geocodingJob = scope.launch(Dispatchers.IO) {
try {
if (!canUseLiveLocation(token)) return@launch
val addresses = geocoderProvider.getFromLocation(
location.latitude,
location.longitude,
1,
liveLocationToken = token
)
if (!isActive || !canUseLiveLocation(token)) return@launch
if (addresses.isNotEmpty()) {
val address = addresses[0]
val names = namesByLevel(address)
LiveLocationPrivacyGate.runIfAllowed(token) {
if (_systemLocationEnabled.value && hasRuntimeLocationPermission()) {
_locationNames.value = names
}
}
} else {
Log.w(TAG, "No reverse geocoding results")
}
} catch (e: Exception) {
if (e !is CancellationException) {
Log.e(TAG, "Reverse geocoding failed")
}
}
}
}
private fun namesByLevel(address: android.location.Address): Map<GeohashChannelLevel, String> {
val dict = mutableMapOf<GeohashChannelLevel, String>()
// Country
address.countryName?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.REGION] = it
}
// Province (state/province or county or city)
address.adminArea?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.PROVINCE] = it
} ?: address.subAdminArea?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.PROVINCE] = it
} ?: address.locality?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.PROVINCE] = it
}
// City (locality)
address.locality?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.CITY] = it
} ?: address.subAdminArea?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.CITY] = it
} ?: address.adminArea?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.CITY] = it
}
// Neighborhood
address.subLocality?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.NEIGHBORHOOD] = it
} ?: address.locality?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.NEIGHBORHOOD] = it
}
// Block: reuse neighborhood/locality granularity without exposing street level
address.subLocality?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.BLOCK] = it
} ?: address.locality?.takeIf { it.isNotEmpty() }?.let {
dict[GeohashChannelLevel.BLOCK] = it
}
return dict
}
// MARK: - Channel Persistence
/**
* Save current channel selection to persistent storage
*/
private fun saveChannelSelection(
channel: ChannelID,
source: LocationSelectionSource?
) {
try {
val channelData = when (channel) {
is ChannelID.Mesh -> gson.toJson(PersistedChannel(mesh = true))
is ChannelID.Location -> gson.toJson(
PersistedChannel(
mesh = false,
level = channel.channel.level.name,
geohash = channel.channel.geohash,
source = source?.name
)
)
}
dataManager?.saveLastGeohashChannel(channelData)
} catch (e: Exception) {
Log.e(TAG, "Failed to save channel selection")
}
}
/**
* Load persisted channel selection from storage
*/
private fun loadPersistedChannelSelection() {
try {
val channelData = dataManager?.loadLastGeohashChannel()
if (!channelData.isNullOrBlank()) {
val persisted = gson.fromJson(channelData, PersistedChannel::class.java)
val channel = persisted?.toChannel()
val source = persisted?.selectionSource()
val canRestore = channel !is ChannelID.Location ||
source == LocationSelectionSource.MANUAL ||
(LiveLocationPrivacyGate.isEnabled &&
_systemLocationEnabled.value &&
_permissionState.value == PermissionState.AUTHORIZED)
if (channel != null && canRestore) {
_selectedChannel.value = channel
selectedLocationSource = if (channel is ChannelID.Location) source else null
_teleported.value = channel is ChannelID.Location &&
source == LocationSelectionSource.MANUAL
} else {
_selectedChannel.value = ChannelID.Mesh
selectedLocationSource = null
_teleported.value = false
saveChannelSelection(ChannelID.Mesh, source = null)
}
} else {
_selectedChannel.value = ChannelID.Mesh
selectedLocationSource = null
}
} catch (e: JsonSyntaxException) {
Log.e(TAG, "Failed to parse persisted channel data")
_selectedChannel.value = ChannelID.Mesh
selectedLocationSource = null
} catch (e: Exception) {
Log.e(TAG, "Failed to load persisted channel")
_selectedChannel.value = ChannelID.Mesh
selectedLocationSource = null
}
}
data class PersistedChannel(
val mesh: Boolean,
val level: String? = null,
val geohash: String? = null,
val source: String? = null
) {
fun toChannel(): ChannelID? {
return if (mesh) {
ChannelID.Mesh
} else {
val levelName = level ?: return null
val gh = geohash ?: return null
ChannelID.Location.fromPersisted(levelName, gh)
}
}
fun selectionSource(): LocationSelectionSource? {
if (mesh) return null
return source?.let {
runCatching { LocationSelectionSource.valueOf(it) }.getOrNull()
} ?: LocationSelectionSource.NEARBY
}
}
/**
* Clear persisted channel selection (useful for testing or reset)
*/
fun clearPersistedChannel() {
dataManager?.clearLastGeohashChannel()
_selectedChannel.value = ChannelID.Mesh
selectedLocationSource = null
_teleported.value = false
}
// MARK: - Location Services State Persistence
/**
* Save location services enabled state to persistent storage
*/
private fun saveLocationServicesState(enabled: Boolean) {
try {
dataManager?.saveLocationServicesEnabled(enabled)
} catch (e: Exception) {
Log.e(TAG, "Failed to save location services state")
}
}
/**
* Load persisted location services state from storage
*/
private fun loadLocationServicesState() {
try {
val enabled = dataManager?.isLocationServicesEnabled() ?: false
LiveLocationPrivacyGate.update(enabled)
} catch (e: Exception) {
Log.e(TAG, "Failed to load location services state")
LiveLocationPrivacyGate.update(false)
}
}
/**
* Cleanup resources
*/
fun cleanup() {
endLiveRefresh()
locationProvider.cancel()
geocodingJob?.cancel()
geocodingJob = null
// Unregister receiver
try { context.unregisterReceiver(locationStateReceiver) } catch (_: Exception) {}
}
}

View File

@ -0,0 +1,40 @@
package com.bitchat.android.geohash
import android.location.Location
/**
* Abstraction for location providers to support both
* System (LocationManager) and Google Play Services (FusedLocationProvider).
*/
internal interface LocationProvider {
/**
* Get the last known location from cache.
* @param callback Called with the location or null if not found/error.
*/
fun getLastKnownLocation(callback: (Location?) -> Unit)
/**
* Request a single, fresh location update.
* @param callback Called with the location or null if failed.
*/
fun requestFreshLocation(callback: (Location?) -> Unit)
/**
* Request continuous location updates.
* @param intervalMs Desired interval in milliseconds.
* @param minDistanceMeters Minimum distance in meters.
* @param callback Called when location updates.
*/
fun requestLocationUpdates(intervalMs: Long, minDistanceMeters: Float, callback: (Location) -> Unit)
/**
* Stop location updates.
* @param callback The same callback instance passed to requestLocationUpdates.
*/
fun removeLocationUpdates(callback: (Location) -> Unit)
/**
* Cancel any pending one-shot location requests and cleanup resources.
*/
fun cancel()
}

View File

@ -0,0 +1,132 @@
package com.bitchat.android.geohash
import android.location.Address
import android.util.Log
import com.bitchat.android.net.OkHttpProvider
import com.google.gson.Gson
import java.io.IOException
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import java.util.Locale
import kotlin.coroutines.resume
class OpenStreetMapGeocoderProvider : GeocoderProvider {
private val TAG = "OSMGeocoderProvider"
private val gson = Gson()
private val userAgent = "Bitchat-Android/1.0"
override suspend fun getFromLocation(
latitude: Double,
longitude: Double,
maxResults: Int,
liveLocationToken: Long?
): List<Address> {
return suspendCancellableCoroutine { continuation ->
val lang = Locale.getDefault().toLanguageTag()
val url = "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=$latitude&lon=$longitude&zoom=18&addressdetails=1&accept-language=$lang"
val request = Request.Builder()
.url(url)
.header("User-Agent", userAgent)
.build()
val call = OkHttpProvider.httpClient().newCall(request)
continuation.invokeOnCancellation { call.cancel() }
val enqueueRequest = {
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isActive) {
Log.w(TAG, "OSM geocoding request failed")
continuation.resume(emptyList())
}
}
override fun onResponse(call: Call, response: Response) {
val addresses = response.use {
if (!it.isSuccessful) {
Log.w(TAG, "OSM geocoding request returned ${it.code}")
return@use emptyList()
}
val body = it.body?.string()
if (body.isNullOrEmpty()) return@use emptyList()
runCatching {
val osmResponse = gson.fromJson(body, OsmResponse::class.java)
if (osmResponse?.address == null) emptyList()
else listOf(mapToAddress(osmResponse, latitude, longitude))
}.getOrElse {
Log.w(TAG, "OSM geocoding response could not be parsed")
emptyList()
}
}
if (continuation.isActive) continuation.resume(addresses)
}
})
}
val started = if (liveLocationToken == null) {
enqueueRequest()
true
} else {
LiveLocationPrivacyGate.runIfAllowed(
liveLocationToken,
enqueueRequest
)
}
if (!started && continuation.isActive) {
continuation.resume(emptyList())
}
}
}
private fun mapToAddress(res: OsmResponse, lat: Double, lon: Double): Address {
val address = Address(Locale.getDefault())
address.latitude = lat
address.longitude = lon
val a = res.address ?: return address
address.countryName = a.country
address.adminArea = a.state
address.subAdminArea = a.county
// City logic similar to Google's mapping
address.locality = a.city ?: a.town ?: a.village ?: a.hamlet
// Neighborhood logic
address.subLocality = a.suburb ?: a.neighbourhood ?: a.residential ?: a.quarter
address.postalCode = a.postcode
address.thoroughfare = a.road
// Feature name
address.featureName = res.name
return address
}
// Data classes for JSON parsing
private data class OsmResponse(
val name: String?,
val display_name: String?,
val address: OsmAddress?
)
private data class OsmAddress(
val country: String?,
val state: String?,
val county: String?,
val city: String?,
val town: String?,
val village: String?,
val hamlet: String?,
val suburb: String?,
val neighbourhood: String?,
val residential: String?,
val quarter: String?,
val postcode: String?,
val road: String?
)
}

View File

@ -0,0 +1,261 @@
package com.bitchat.android.geohash
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.util.Log
import androidx.core.app.ActivityCompat
internal class SystemLocationProvider(private val context: Context) : LocationProvider {
companion object {
private const val TAG = "SystemLocationProvider"
}
private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
// Map to keep track of listeners to unregister them later
private val activeListeners = mutableMapOf<(Location) -> Unit, LocationListener>()
private val activeOneShotListeners = mutableMapOf<(Location?) -> Unit, LocationListener>()
private val activeOneShotRunnables = mutableMapOf<(Location?) -> Unit, Runnable>()
private val activeOneShotCancellationSignals = mutableMapOf<(Location?) -> Unit, CancellationSignal>()
private fun hasLocationPermission(): Boolean {
return LiveLocationPrivacyGate.isEnabled &&
(ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
)
}
@SuppressLint("MissingPermission")
override fun getLastKnownLocation(callback: (Location?) -> Unit) {
if (!hasLocationPermission()) {
callback(null)
return
}
try {
var bestLocation: Location? = null
val providers = locationManager.getProviders(true)
for (provider in providers) {
val location = locationManager.getLastKnownLocation(provider)
if (location != null) {
if (bestLocation == null || location.time > bestLocation.time) {
bestLocation = location
}
}
}
callback(bestLocation.takeIf { LiveLocationPrivacyGate.isEnabled })
} catch (e: Exception) {
Log.e(TAG, "Error getting last-known location")
callback(null)
}
}
@SuppressLint("MissingPermission")
override fun requestFreshLocation(callback: (Location?) -> Unit) {
if (!hasLocationPermission()) {
callback(null)
return
}
try {
val providers = listOf(
LocationManager.GPS_PROVIDER,
LocationManager.NETWORK_PROVIDER,
LocationManager.PASSIVE_PROVIDER
)
var providerFound = false
for (provider in providers) {
if (locationManager.isProviderEnabled(provider)) {
Log.d(TAG, "Requesting fresh location from $provider")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val cancellationSignal = CancellationSignal()
synchronized(activeOneShotCancellationSignals) {
activeOneShotCancellationSignals[callback] = cancellationSignal
}
try {
locationManager.getCurrentLocation(
provider,
cancellationSignal,
context.mainExecutor
) { location ->
synchronized(activeOneShotCancellationSignals) {
activeOneShotCancellationSignals.remove(callback)
}
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
}
} catch (e: Exception) {
synchronized(activeOneShotCancellationSignals) {
activeOneShotCancellationSignals.remove(callback)
}
cancellationSignal.cancel()
throw e
}
} else {
// For older versions, use requestSingleUpdate with timeout mechanism
val timeoutRunnable = Runnable {
Log.w(TAG, "Location request timed out")
synchronized(activeOneShotListeners) {
val listener = activeOneShotListeners.remove(callback)
activeOneShotRunnables.remove(callback)
if (listener != null) {
try {
locationManager.removeUpdates(listener)
} catch (e: Exception) {
Log.e(TAG, "Error removing timed-out listener")
}
}
}
callback(null)
}
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
synchronized(activeOneShotListeners) {
activeOneShotListeners.remove(callback)
val runnable = activeOneShotRunnables.remove(callback)
if (runnable != null) {
handler.removeCallbacks(runnable)
}
}
try {
locationManager.removeUpdates(this)
} catch (e: Exception) {
Log.e(TAG, "Error removing updates in callback")
}
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
synchronized(activeOneShotListeners) {
activeOneShotListeners[callback] = listener
activeOneShotRunnables[callback] = timeoutRunnable
}
locationManager.requestSingleUpdate(provider, listener, null)
handler.postDelayed(timeoutRunnable, 30000L) // 30s timeout
}
providerFound = true
break
}
}
if (!providerFound) {
Log.w(TAG, "No location providers available for fresh location")
callback(null)
}
} catch (e: Exception) {
Log.e(TAG, "Error requesting fresh location")
callback(null)
}
}
@SuppressLint("MissingPermission")
override fun requestLocationUpdates(
intervalMs: Long,
minDistanceMeters: Float,
callback: (Location) -> Unit
) {
if (!hasLocationPermission()) return
try {
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
if (LiveLocationPrivacyGate.isEnabled) callback(location)
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
}
// Store the listener so we can remove it later
synchronized(activeListeners) {
activeListeners[callback] = listener
}
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
var registered = false
for (provider in providers) {
if (locationManager.isProviderEnabled(provider)) {
locationManager.requestLocationUpdates(
provider,
intervalMs,
minDistanceMeters,
listener
)
registered = true
Log.d(TAG, "Registered updates for $provider")
}
}
if (!registered) {
Log.w(TAG, "No providers enabled for continuous updates")
}
} catch (e: Exception) {
Log.e(TAG, "Error requesting location updates")
}
}
override fun removeLocationUpdates(callback: (Location) -> Unit) {
try {
val listener = synchronized(activeListeners) {
activeListeners.remove(callback)
}
if (listener != null) {
locationManager.removeUpdates(listener)
Log.d(TAG, "Removed location updates")
}
} catch (e: Exception) {
Log.e(TAG, "Error removing updates")
}
}
override fun cancel() {
try {
// Cancel continuous updates
synchronized(activeListeners) {
for ((_, listener) in activeListeners) {
try { locationManager.removeUpdates(listener) } catch (_: Exception) {}
}
activeListeners.clear()
}
// Cancel one-shot requests
synchronized(activeOneShotListeners) {
for ((_, listener) in activeOneShotListeners) {
try { locationManager.removeUpdates(listener) } catch (_: Exception) {}
}
activeOneShotListeners.clear()
for ((_, runnable) in activeOneShotRunnables) {
handler.removeCallbacks(runnable)
}
activeOneShotRunnables.clear()
}
synchronized(activeOneShotCancellationSignals) {
activeOneShotCancellationSignals.values.forEach { it.cancel() }
activeOneShotCancellationSignals.clear()
}
Log.d(TAG, "Cancelled all system location requests")
} catch (e: Exception) {
Log.e(TAG, "Error cancelling system provider")
}
}
}

View File

@ -0,0 +1,323 @@
package com.bitchat.android.hotspot
import android.content.Context
import android.util.Log
import fi.iki.elonen.NanoHTTPD
import java.io.File
import java.io.FileInputStream
/**
* Lightweight HTTP server for serving the universal APK over Wi-Fi P2P hotspot.
* Based on NanoHTTPD.
*/
class ApkWebServer(
private val context: Context,
private val apkFile: File,
private val port: Int = DEFAULT_PORT
) : NanoHTTPD(port) {
companion object {
private const val TAG = "ApkWebServer"
const val DEFAULT_PORT = 9999
}
private val appVersion: String by lazy {
try {
context.packageManager
.getPackageArchiveInfo(apkFile.absolutePath, 0)
?.versionName
?: "Unknown"
} catch (e: Exception) {
"Unknown"
}
}
// Cache the HTML landing page (generated once, reused for all requests)
private val cachedHtml: String by lazy {
generateLandingPageHtml()
}
override fun serve(session: IHTTPSession): Response {
val uri = session.uri ?: "/"
Log.d(TAG, "Request: ${session.method} $uri from ${session.remoteIpAddress}")
return when {
uri == "/bitchat.apk" -> {
serveApk()
}
uri == "/favicon.ico" -> {
newFixedLengthResponse(Response.Status.NOT_FOUND, "text/plain", "Not found")
}
else -> {
serveLandingPage()
}
}
}
/**
* Serve the APK file.
*/
private fun serveApk(): Response {
return try {
if (!apkFile.exists()) {
Log.e(TAG, "APK file not found: ${apkFile.path}")
return newFixedLengthResponse(
Response.Status.NOT_FOUND,
"text/plain",
"APK file not found"
)
}
Log.d(TAG, "Serving APK: ${apkFile.name} (${apkFile.length() / 1024 / 1024}MB)")
val inputStream = FileInputStream(apkFile)
val response = newFixedLengthResponse(
Response.Status.OK,
"application/vnd.android.package-archive",
inputStream,
apkFile.length()
)
response.addHeader("Content-Disposition", "attachment; filename=\"bitchat-${appVersion}.apk\"")
response.addHeader("Accept-Ranges", "bytes")
response
} catch (e: Exception) {
Log.e(TAG, "Error serving APK", e)
newFixedLengthResponse(
Response.Status.INTERNAL_ERROR,
"text/plain",
"Error serving APK: ${e.message}"
)
}
}
/**
* Serve the HTML landing page.
*/
private fun serveLandingPage(): Response {
return newFixedLengthResponse(
Response.Status.OK,
"text/html",
cachedHtml
)
}
/**
* Generate HTML landing page.
*/
private fun generateLandingPageHtml(): String {
val apkSizeMb = apkFile.length() / 1024 / 1024
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Download BitChat</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: #333;
}
.container {
background: white;
border-radius: 20px;
padding: 40px 30px;
max-width: 500px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
text-align: center;
}
.logo {
font-size: 64px;
margin-bottom: 20px;
}
h1 {
font-size: 32px;
margin-bottom: 10px;
color: #667eea;
}
.subtitle {
font-size: 16px;
color: #666;
margin-bottom: 30px;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 30px;
}
.info-box {
background: #f5f7fa;
padding: 15px;
border-radius: 10px;
}
.info-label {
font-size: 12px;
color: #888;
text-transform: uppercase;
font-weight: 600;
margin-bottom: 5px;
}
.info-value {
font-size: 18px;
font-weight: bold;
color: #333;
}
.download-button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 18px 40px;
border-radius: 50px;
text-decoration: none;
font-size: 18px;
font-weight: 600;
margin-bottom: 30px;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.download-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
}
.download-button:active {
transform: translateY(0);
}
.instructions {
text-align: left;
background: #f5f7fa;
padding: 20px;
border-radius: 10px;
margin-top: 20px;
}
.instructions h3 {
font-size: 16px;
margin-bottom: 15px;
color: #667eea;
}
.instructions ol {
margin-left: 20px;
}
.instructions li {
margin-bottom: 10px;
line-height: 1.6;
font-size: 14px;
color: #555;
}
.warning {
background: #fff3cd;
border: 1px solid #ffc107;
padding: 15px;
border-radius: 10px;
margin-top: 20px;
font-size: 13px;
color: #856404;
text-align: left;
}
.warning strong {
display: block;
margin-bottom: 5px;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">🔒</div>
<h1>BitChat</h1>
<p class="subtitle">Secure Mesh Messaging</p>
<div class="info-grid">
<div class="info-box">
<div class="info-label">Version</div>
<div class="info-value">$appVersion</div>
</div>
<div class="info-box">
<div class="info-label">Size</div>
<div class="info-value">${apkSizeMb} MB</div>
</div>
</div>
<a href="/bitchat.apk" class="download-button">
📥 Download BitChat
</a>
<div class="instructions">
<h3>📱 Installation Instructions</h3>
<ol>
<li>Tap the download button above</li>
<li>Wait for the download to complete</li>
<li>Open the downloaded APK file</li>
<li>If prompted, enable "Install from unknown sources" for your browser</li>
<li>Follow the installation prompts</li>
</ol>
</div>
<div class="warning">
<strong> Note:</strong>
If you already have BitChat installed, you may need to uninstall it first before installing this version. Make sure to backup your data if needed.
</div>
</div>
</body>
</html>
""".trimIndent()
}
/**
* Start the server.
*/
fun startServer() {
try {
start(NanoHTTPD.SOCKET_READ_TIMEOUT, false)
Log.d(TAG, "Web server started on port $port")
} catch (e: Exception) {
Log.e(TAG, "Failed to start web server", e)
throw e
}
}
/**
* Stop the server.
*/
fun stopServer() {
try {
stop()
Log.d(TAG, "Web server stopped")
} catch (e: Exception) {
Log.e(TAG, "Error stopping web server", e)
}
}
}

View File

@ -0,0 +1,706 @@
package com.bitchat.android.hotspot
import android.content.Intent
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.util.UniversalApkManager
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import java.io.File
/**
* Activity for managing Wi-Fi P2P hotspot for offline APK sharing.
* Pure Compose implementation, no fragments.
*/
class HotspotActivity : ComponentActivity() {
companion object {
const val EXTRA_APK_PATH = "apk_path"
private const val TAG = "HotspotActivity"
}
private val viewModel: HotspotViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Get APK path from intent
val apkPath = intent.getStringExtra(EXTRA_APK_PATH)
val apkFile = if (apkPath != null) {
File(apkPath)
} else {
// Fallback: Try to get cached APK
UniversalApkManager(this).getCachedApk()
}
if (apkFile == null || !apkFile.exists()) {
// No APK available, show error and finish
finish()
return
}
setContent {
BitchatTheme {
HotspotScreen(
viewModel = viewModel,
apkFile = apkFile,
onClose = { finish() }
)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Handle notification action to stop hotspot
if (intent.action == "STOP_HOTSPOT") {
viewModel.stopHotspot()
finish()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HotspotScreen(
viewModel: HotspotViewModel,
apkFile: File,
onClose: () -> Unit
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = "Share BitChat",
fontFamily = BitchatFontFamily
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { padding ->
Crossfade(
targetState = state,
label = "HotspotStateCrossfade",
modifier = Modifier.padding(padding)
) { currentState ->
when (currentState) {
is HotspotViewModel.HotspotState.Intro -> {
IntroScreen(
onStartHotspot = { viewModel.startHotspot(apkFile) }
)
}
is HotspotViewModel.HotspotState.Starting -> {
LoadingScreen()
}
is HotspotViewModel.HotspotState.ConfirmDisconnect -> {
ExistingGroupConfirmation(
onConfirm = viewModel::confirmDisconnectAndStart,
onCancel = viewModel::cancelDisconnect
)
}
is HotspotViewModel.HotspotState.Active -> {
ActiveHotspotScreen(state = currentState)
}
is HotspotViewModel.HotspotState.Error -> {
ErrorScreen(
message = currentState.message,
onRetry = { viewModel.resetToIntro() },
onClose = onClose
)
}
}
}
}
}
@Composable
private fun ExistingGroupConfirmation(
onConfirm: () -> Unit,
onCancel: () -> Unit
) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text(stringResource(R.string.hotspot_disconnect_title)) },
text = { Text(stringResource(R.string.hotspot_disconnect_message)) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.hotspot_disconnect_confirm))
}
},
dismissButton = {
TextButton(onClick = onCancel) {
Text(stringResource(R.string.cancel))
}
}
)
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun IntroScreen(onStartHotspot: () -> Unit) {
val requiredPermissions = remember { HotspotPermissions.requiredForSdk() }
val permissionState = rememberMultiplePermissionsState(requiredPermissions) { results ->
if (requiredPermissions.all { results[it] == true }) {
onStartHotspot()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Spacer(modifier = Modifier.height(32.dp))
Icon(
imageVector = Icons.Default.Wifi,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Offline App Sharing",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "How it works:",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
InfoItem("1. Your device creates a Wi-Fi hotspot")
InfoItem("2. Others connect to your hotspot")
InfoItem("3. They scan a QR code or enter a URL")
InfoItem("4. BitChat downloads directly to their device")
}
}
// Permission rationale (if needed)
if (!permissionState.allPermissionsGranted && permissionState.shouldShowRationale) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = " Permission Required",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = when {
Build.VERSION.SDK_INT >= HotspotPermissions.ANDROID_17_API_LEVEL ->
"BitChat needs nearby devices and local network access to create a Wi-Fi hotspot and serve the app to connected devices."
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ->
"BitChat needs nearby devices permission to create a Wi-Fi hotspot for sharing the app offline."
else ->
"BitChat needs location permission to create a Wi-Fi hotspot. This is required by Android for Wi-Fi scanning, but no location data is collected."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "⚠️ Note",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
Text(
text = "This will create a Wi-Fi hotspot on your device. Your current Wi-Fi connection may be interrupted.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
// Check permission before starting hotspot
if (permissionState.allPermissionsGranted) {
// No permission needed or already granted
onStartHotspot()
} else {
// Request permission (auto-start handled by onPermissionResult callback)
permissionState.launchMultiplePermissionRequest()
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(16.dp)
) {
Text(
// Starting the hotspot is the user's action. Android will ask
// for the required permission only when it has not already
// been granted.
text = "Start Hotspot",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
}
}
@Composable
fun InfoItem(text: String) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top
) {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
)
}
}
@Composable
fun LoadingScreen() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp)
)
Text(
text = "Starting hotspot...",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
@Composable
fun ActiveHotspotScreen(state: HotspotViewModel.HotspotState.Active) {
var selectedTab by remember { mutableStateOf(0) }
val tabs = listOf("Wi-Fi", "Website")
Column(
modifier = Modifier.fillMaxSize()
) {
// Status banner
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "Hotspot Active",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = "${state.connectedPeers} device(s) connected",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
Icon(
imageVector = Icons.Default.Wifi,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
}
}
// Tabs
TabRow(
selectedTabIndex = selectedTab,
containerColor = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.primary
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = {
Text(
text = title,
fontFamily = BitchatFontFamily,
fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal
)
}
)
}
}
// Tab content
when (selectedTab) {
0 -> WifiTabContent(
ssid = state.ssid,
password = state.password
)
1 -> WebsiteTabContent(
ipAddress = state.ipAddress,
port = state.port
)
}
}
}
@Composable
fun WifiTabContent(ssid: String, password: String) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
text = "Step 1: Connect to Wi-Fi",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Text(
text = "Have others scan this QR code to connect:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
textAlign = TextAlign.Center
)
// QR Code
val qrSize = with(LocalDensity.current) { 280.dp.toPx().toInt() }
val wifiQr = remember(ssid, password, qrSize) {
QrCodeGenerator.generateWifiQr(ssid, password, qrSize)
}
if (wifiQr != null) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(16.dp)
) {
Image(
bitmap = wifiQr.asImageBitmap(),
contentDescription = "Wi-Fi QR Code",
modifier = Modifier.size(280.dp)
)
}
}
Text(
text = "Or enter manually:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
// SSID
CredentialCard(
label = "Network Name (SSID)",
value = ssid,
onCopy = {
clipboardManager.setText(AnnotatedString(ssid))
}
)
// Password
CredentialCard(
label = "Password",
value = password,
onCopy = {
clipboardManager.setText(AnnotatedString(password))
}
)
}
}
@Composable
fun WebsiteTabContent(ipAddress: String, port: Int) {
val url = "http://$ipAddress:$port"
val clipboardManager = LocalClipboardManager.current
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Text(
text = "Step 2: Download BitChat",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Text(
text = "After connecting to the Wi-Fi, scan this QR code:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
textAlign = TextAlign.Center
)
// QR Code
val qrSize = with(LocalDensity.current) { 280.dp.toPx().toInt() }
val urlQr = remember(url, qrSize) {
QrCodeGenerator.generateUrlQr(url, qrSize)
}
if (urlQr != null) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(16.dp)
) {
Image(
bitmap = urlQr.asImageBitmap(),
contentDescription = "Website URL QR Code",
modifier = Modifier.size(280.dp)
)
}
}
Text(
text = "Or open in browser:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
// URL
CredentialCard(
label = "Website URL",
value = url,
onCopy = {
clipboardManager.setText(AnnotatedString(url))
}
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "📱 Instructions",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Text(
text = "1. Make sure you're connected to the Wi-Fi network above\n" +
"2. Open a web browser on your device\n" +
"3. Visit the URL above or scan the QR code\n" +
"4. Tap 'Download BitChat'\n" +
"5. Install the downloaded APK",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
}
@Composable
fun CredentialCard(
label: String,
value: String,
onCopy: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.bodyLarge,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
IconButton(onClick = onCopy) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Copy",
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
}
@Composable
fun ErrorScreen(
message: String,
onRetry: () -> Unit,
onClose: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "",
fontSize = 64.sp
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Error",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onRetry,
modifier = Modifier.fillMaxWidth()
) {
Text("Try Again")
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = onClose) {
Text("Close")
}
}
}

View File

@ -0,0 +1,879 @@
package com.bitchat.android.hotspot
import android.Manifest
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.wifi.p2p.WifiP2pConfig
import android.net.wifi.p2p.WifiP2pGroup
import android.net.wifi.p2p.WifiP2pManager
import android.net.wifi.p2p.WifiP2pManager.*
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import java.net.NetworkInterface
import java.security.SecureRandom
import kotlin.random.Random
/**
* Manages Wi-Fi P2P (Wi-Fi Direct) hotspot for offline APK sharing.
* Based on Briar's implementation.
*/
class HotspotManager(private val context: Context) {
companion object {
private const val TAG = "HotspotMgr"
// Group info polling interval
private const val GROUP_INFO_POLL_INTERVAL_MILLIS = 1000L
// Give up if the group never forms within this window after creation succeeded
private const val GROUP_FORMATION_TIMEOUT_MILLIS = 15_000L
// SSID and password configuration
private const val SSID_SUFFIX_LENGTH = 8
private const val PASSWORD_LENGTH = 16
// Records the group we created so a later run can tell our own orphan apart
// from a group belonging to Cast, Android Auto or Quick Share.
private const val PREFS_NAME = "hotspot"
private const val KEY_OWNED_GROUP = "owned_group_name"
// Characters to use for random generation (excluding confusing ones)
private const val RANDOM_CHARS = "ABCDEFGHJKLMNPQRTUVWXY34679" // No 0,O,5,S,1,l,I
}
private val wifiP2pManager: WifiP2pManager? =
context.getSystemService(Context.WIFI_P2P_SERVICE) as? WifiP2pManager
private var channel: Channel? = null
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: android.net.wifi.WifiManager.WifiLock? = null
private val handler = Handler(Looper.getMainLooper())
private val random = SecureRandom()
private var currentGroup: WifiP2pGroup? = null
private var callback: HotspotCallback? = null
private var isStarting = false
private var hasNotifiedStarted = false // Track if we've notified the callback
private var isReceiverRegistered = false // Track receiver registration to prevent leaks
// Set once our own createGroup command is accepted, and re-checked against every
// group snapshot afterwards. stopHotspot() only calls removeGroup() when this is
// true: removal is device-scoped, so issuing it when the group on the framework
// is not ours could only tear down another app's session (Cast, Android Auto,
// Quick Share).
private var createdGroup = false
// Framework-reported name of the group this session hosts, once known. Null while
// the group is still forming, when a null snapshot carries no information.
private var hostedGroupName: String? = null
// Name of the foreign group the user explicitly agreed to disconnect, or null.
// Consent is per-group: a group with a different name asks again.
private var confirmedReplacementName: String? = null
// stopHotspot() can be reached again while its group query/removal is still in
// flight. Later callers wait for that same teardown instead of releasing the
// Wi-Fi Aware lease early.
private var teardownInProgress = false
private val teardownCallbacks = mutableListOf<() -> Unit>()
// Saved credentials for reconnection
private var savedSsid: String? = null
private var savedPassword: String? = null
// Last Wi-Fi P2P state seen on the broadcast, or null before the first one arrives
private var lastP2pState: Int? = null
private val prefs by lazy { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) }
/** Network name of the last group this app created, surviving process death. */
private var ownedGroupName: String?
get() = prefs.getString(KEY_OWNED_GROUP, null)
set(value) = prefs.edit().putString(KEY_OWNED_GROUP, value).apply()
// Broadcast receiver for Wi-Fi P2P events
private val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION -> {
val state = intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, -1)
lastP2pState = state
Log.d(TAG, "Wi-Fi P2P state changed: $state")
// Wi-Fi Direct going away is terminal for this session: without it
// the group cannot form, and any group already up is now dead.
if (state == WIFI_P2P_STATE_DISABLED && (isStarting || hasNotifiedStarted)) {
Log.w(TAG, "Wi-Fi P2P was disabled; aborting hotspot")
failStartup(HotspotStartupPolicy.P2P_DISABLED_MESSAGE)
}
}
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
Log.d(TAG, "Wi-Fi P2P connection changed")
requestGroupInfo()
}
}
}
}
/**
* Start the Wi-Fi P2P hotspot.
*
* @param confirmedReplacementName name of the foreign Wi-Fi Direct group the
* user has confirmed may be disconnected, as previously reported through
* [HotspotCallback.onExistingGroupConflict]. When null (or when the group
* present no longer matches), a foreign group is reported instead of touched.
*/
fun startHotspot(callback: HotspotCallback, confirmedReplacementName: String? = null) {
if (isStarting) {
Log.w(TAG, "Hotspot already starting")
return
}
if (wifiP2pManager == null) {
Log.e(TAG, "Wi-Fi P2P not available on this device")
callback.onError("Wi-Fi Direct not supported on this device")
return
}
val missingPermissions = HotspotPermissions.missingFrom(context)
if (missingPermissions.isNotEmpty()) {
Log.w(TAG, "Cannot start hotspot; missing required permissions: $missingPermissions")
val message = if (Manifest.permission.ACCESS_LOCAL_NETWORK in missingPermissions) {
"Local network permission is required to share the app over the hotspot"
} else {
"Nearby Wi-Fi permission is required to start the hotspot"
}
callback.onError(message)
return
}
this.callback = callback
this.confirmedReplacementName = confirmedReplacementName
isStarting = true
Log.d(TAG, "Starting Wi-Fi P2P hotspot")
// Register broadcast receiver (only if not already registered)
if (!isReceiverRegistered) {
val intentFilter = IntentFilter().apply {
addAction(WIFI_P2P_STATE_CHANGED_ACTION)
addAction(WIFI_P2P_CONNECTION_CHANGED_ACTION)
}
context.registerReceiver(broadcastReceiver, intentFilter)
isReceiverRegistered = true
Log.d(TAG, "Broadcast receiver registered")
}
// Acquire locks
acquireLocks()
// Load or generate credentials
if (savedSsid == null || savedPassword == null) {
savedSsid = generateSsid()
savedPassword = generatePassword()
Log.d(TAG, "Generated new credentials: SSID=$savedSsid")
} else {
Log.d(TAG, "Using saved credentials: SSID=$savedSsid")
}
// Start P2P framework (retries reuse this one channel)
startWifiP2pFramework()
}
/**
* Stop the hotspot.
*
* @param onTeardownComplete invoked once the framework has acknowledged the
* removal of our group (or immediately when this session created none). Lets
* the caller hold the Wi-Fi Aware radio back until the P2P group is gone.
*/
fun stopHotspot(onTeardownComplete: (() -> Unit)? = null) {
Log.d(TAG, "Stopping hotspot")
onTeardownComplete?.let(teardownCallbacks::add)
if (teardownInProgress) {
Log.d(TAG, "Teardown already in progress; chaining completion")
return
}
isStarting = false
hasNotifiedStarted = false
// Stop group info polling
handler.removeCallbacksAndMessages(null)
// Detach the channel first so any in-flight listener sees the hotspot as stopped,
// then remove the group and close the channel once the framework has replied.
val staleChannel = channel
channel = null
val hadOwnGroup = createdGroup
val expectedGroupName = hostedGroupName ?: if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
) {
savedSsid
} else {
null
}
createdGroup = false
hostedGroupName = null
var teardownAction: (() -> Unit)? = null
if (staleChannel != null && hadOwnGroup) {
teardownInProgress = true
teardownAction = {
removeOwnGroupIfStillPresent(staleChannel, expectedGroupName)
}
} else if (staleChannel != null) {
// This session created nothing, so there is nothing of ours to remove.
// removeGroup() here is exactly the bug this change fixes: device-scoped
// removal would disconnect whatever group another app has running.
closeChannel(staleChannel)
}
// Release locks
releaseLocks()
// Unregister receiver (only if registered)
if (isReceiverRegistered) {
try {
context.unregisterReceiver(broadcastReceiver)
isReceiverRegistered = false
Log.d(TAG, "Broadcast receiver unregistered")
} catch (e: IllegalArgumentException) {
Log.w(TAG, "Receiver was not registered", e)
isReceiverRegistered = false
}
}
currentGroup = null
callback = null
if (teardownAction != null) {
teardownAction.invoke()
} else {
finishTeardown()
}
}
/**
* Re-check the device-scoped group immediately before removing it. The last poll
* is only a snapshot: our group may have disappeared and another app may have
* claimed Wi-Fi Direct before stop was requested.
*/
@SuppressLint("MissingPermission")
private fun removeOwnGroupIfStillPresent(ch: Channel, expectedGroupName: String?) {
val manager = wifiP2pManager ?: run {
closeChannel(ch)
finishTeardown()
return
}
try {
manager.requestGroupInfo(ch) { group ->
val stillOurs = HotspotStartupPolicy.isExpectedHostedGroup(
existingGroupName = group?.networkName,
isGroupOwner = group?.isGroupOwner == true,
expectedGroupName = expectedGroupName
)
if (!stillOurs) {
Log.i(
TAG,
"Current group '${group?.networkName}' is not ours " +
"('$expectedGroupName'); leaving it alone"
)
closeChannel(ch)
finishTeardown()
return@requestGroupInfo
}
try {
manager.removeGroup(ch, object : ActionListener {
override fun onSuccess() {
Log.d(TAG, "Group removed successfully")
clearOwnedGroupNameIfMatches(expectedGroupName)
closeChannel(ch)
finishTeardown()
}
override fun onFailure(reason: Int) {
Log.w(TAG, "Failed to remove group: $reason")
closeChannel(ch)
finishTeardown()
}
})
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while removing the group", e)
closeChannel(ch)
finishTeardown()
}
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while confirming group ownership", e)
closeChannel(ch)
finishTeardown()
}
}
private fun clearOwnedGroupNameIfMatches(removedGroupName: String?) {
if (HotspotStartupPolicy.shouldClearOwnedGroupName(ownedGroupName, removedGroupName)) {
ownedGroupName = null
}
}
private fun finishTeardown() {
teardownInProgress = false
val callbacks = teardownCallbacks.toList()
teardownCallbacks.clear()
callbacks.forEach { it.invoke() }
}
/**
* Release the channel's binder registration with WifiP2pService. Without this the
* registration survives until the process dies, and every start/stop cycle adds
* another stale client to the framework's list.
*/
private fun closeChannel(channelToClose: Channel) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) return
try {
channelToClose.close()
Log.d(TAG, "P2P channel closed")
} catch (e: Exception) {
Log.w(TAG, "Error closing P2P channel", e)
}
}
/**
* Get current connection information.
*/
fun getConnectionInfo(): ConnectionInfo? {
val group = currentGroup ?: return null
val ipAddress = getAccessPointAddress()
return ConnectionInfo(
ssid = group.networkName ?: savedSsid ?: "",
password = group.passphrase ?: savedPassword ?: "",
ipAddress = ipAddress ?: "192.168.49.1", // Fallback to standard P2P IP
connectedPeers = group.clientList?.size ?: 0
)
}
/**
* Initialise the P2P framework once. Every retry reuses this channel calling
* initialize() per attempt registers a fresh binder with WifiP2pService that is
* never reclaimed until the process dies.
*/
private fun startWifiP2pFramework() {
Log.d(TAG, "Initialising P2P channel")
val newChannel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null)
if (newChannel == null) {
// The service is unobtainable; retrying will not change that.
Log.e(TAG, "Failed to initialize P2P channel")
failStartup(HotspotStartupPolicy.P2P_UNSUPPORTED_MESSAGE)
return
}
channel = newChannel
createGroupWhenP2pAvailable()
}
/**
* Ask the framework for the current P2P state before the first attempt.
*
* When P2P is disabled the state machine answers every createGroup with BUSY
* the same code a genuinely transient collision returns so without this check
* a permanent failure is indistinguishable from a retryable one.
*/
@SuppressLint("MissingPermission")
private fun createGroupWhenP2pAvailable() {
val ch = channel ?: return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
clearStaleGroupThenCreate(ch, attempt = 1)
return
}
try {
wifiP2pManager?.requestP2pState(ch) { state ->
if (channel !== ch) return@requestP2pState
lastP2pState = state
clearStaleGroupThenCreate(ch, attempt = 1)
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while reading P2P state", e)
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
}
}
/**
* A P2P group survives the process that created it, so a previous session killed
* while hosting leaves an orphan behind. The framework then rejects createGroup
* with BUSY for as long as that group exists, which no retry can clear.
*/
@SuppressLint("MissingPermission")
private fun clearStaleGroupThenCreate(ch: Channel, attempt: Int) {
try {
wifiP2pManager?.requestGroupInfo(ch) { existingGroup ->
if (channel !== ch) return@requestGroupInfo
val action = HotspotStartupPolicy.startAction(
p2pState = lastP2pState,
existingGroupName = existingGroup?.networkName,
ownedGroupName = ownedGroupName,
confirmedGroupName = confirmedReplacementName
)
when (action) {
is HotspotStartupPolicy.StartAction.Fail -> {
Log.w(TAG, "Not attempting group creation: ${action.message}")
failStartup(action.message)
}
HotspotStartupPolicy.StartAction.ConfirmReplaceExisting -> {
val name = existingGroup?.networkName
if (name == null) {
// Unreachable while the policy requires a name, but there
// is nothing safe to bind consent to without one.
failStartup(HotspotStartupPolicy.P2P_BUSY_MESSAGE)
} else {
Log.i(TAG, "Existing group '$name' is not ours; asking the user")
reportExistingGroupConflict(name)
}
}
HotspotStartupPolicy.StartAction.Create ->
createGroup(attempt, oldGroupCleared = true)
HotspotStartupPolicy.StartAction.RemoveStaleGroupThenCreate -> {
Log.w(TAG, "Removing stale group '${existingGroup?.networkName}' before creating")
removeStaleGroup(ch, attempt)
}
}
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while reading group info", e)
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
}
}
private fun removeStaleGroup(ch: Channel, attempt: Int) {
try {
wifiP2pManager?.removeGroup(ch, object : ActionListener {
override fun onSuccess() {
if (channel !== ch) return
Log.d(TAG, "Stale group removed")
createGroup(attempt, oldGroupCleared = true)
}
override fun onFailure(reason: Int) {
if (channel !== ch) return
// Creation may still succeed, and a BUSY reply here backs off as usual.
Log.w(TAG, "Failed to remove stale group: $reason; attempting creation anyway")
createGroup(attempt, oldGroupCleared = false)
}
})
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while removing the existing group", e)
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
}
}
/**
* Create Wi-Fi P2P group.
*
* @param oldGroupCleared false when a previous group may still exist (its removal
* just failed). The ownership marker keeps the OLD group's name in that case:
* overwriting it early would make a BUSY retry classify our own stale group as
* foreign and raise a spurious consent dialog. On success the group-info poll
* records the authoritative name anyway.
*/
@SuppressLint("MissingPermission")
private fun createGroup(attempt: Int, oldGroupCleared: Boolean) {
val ch = channel ?: return
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Record before the call: if the process dies between creation and the
// first group info, the next run still knows this orphan is ours.
if (oldGroupCleared) {
ownedGroupName = savedSsid
}
// Android 10+: Custom SSID and password
val config = WifiP2pConfig.Builder()
.setNetworkName(savedSsid!!)
.setPassphrase(savedPassword!!)
.setGroupOperatingBand(WifiP2pConfig.GROUP_OWNER_BAND_2GHZ) // Force 2.4GHz for compatibility
.build()
wifiP2pManager?.createGroup(ch, config, groupActionListener(attempt, ch))
} else {
// Android 9 and below: System-generated SSID/password
wifiP2pManager?.createGroup(ch, groupActionListener(attempt, ch))
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while creating the group", e)
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
}
}
private fun groupActionListener(attempt: Int, requestChannel: Channel) = object : ActionListener {
override fun onSuccess() {
if (channel !== requestChannel) {
// Ours by construction: this listener only observes our own createGroup.
Log.w(TAG, "Removing group created after hotspot was stopped")
try {
wifiP2pManager?.removeGroup(requestChannel, null)
} catch (e: SecurityException) {
// The orphan stays; the next start recognises it via ownedGroupName.
Log.e(TAG, "Could not remove the late group; permission was revoked", e)
}
return
}
Log.d(TAG, "P2P group created successfully")
createdGroup = true
isStarting = false
// Don't call onHotspotStarted() yet - wait for group info
startGroupInfoPolling()
}
override fun onFailure(reason: Int) {
if (channel != null) {
handleGroupCreationFailure(reason, attempt)
}
}
}
/**
* Handle group creation failure, backing off only for genuinely transient causes.
*/
private fun handleGroupCreationFailure(reason: Int, attempt: Int) {
val reasonStr = when (reason) {
ERROR -> "ERROR"
P2P_UNSUPPORTED -> "P2P_UNSUPPORTED"
BUSY -> "BUSY"
else -> "UNKNOWN($reason)"
}
Log.w(
TAG,
"Failed to create group: $reasonStr " +
"(attempt $attempt/${HotspotStartupPolicy.MAX_ATTEMPTS}, p2pState=$lastP2pState)"
)
when (val decision = HotspotStartupPolicy.decide(reason, attempt, lastP2pState)) {
is HotspotStartupPolicy.Decision.Retry -> {
Log.d(TAG, "Retrying group creation in ${decision.delayMillis}ms")
handler.postDelayed({
// Re-check for a stale group each round: BUSY is also how the
// framework reports "a group already exists".
channel?.let { clearStaleGroupThenCreate(it, attempt + 1) }
}, decision.delayMillis)
}
is HotspotStartupPolicy.Decision.Fail -> failStartup(decision.message)
}
}
/**
* Terminal startup failure: release all resources (locks, receiver, handler
* callbacks) before notifying the callback, so a failed attempt doesn't leak
* and block subsequent attempts.
*/
private fun failStartup(message: String) {
val cb = callback
stopHotspot()
cb?.onError(message)
}
/**
* A group belonging to another app is up. Stop cleanly with [createdGroup]
* false the stop path leaves that group untouched and let the UI ask whether
* starting the hotspot may disconnect it.
*/
private fun reportExistingGroupConflict(groupName: String) {
val cb = callback
stopHotspot()
cb?.onExistingGroupConflict(groupName)
}
/**
* Start polling for group info to track connected clients.
*/
private fun startGroupInfoPolling() {
requestGroupInfo()
// Keep polling even while the group info is still null — the first
// requestGroupInfo() after createGroup() can legitimately return null
// while the group is forming. Give up only after a timeout.
var elapsedMillis = 0L
handler.postDelayed(object : Runnable {
override fun run() {
if (channel == null) return
elapsedMillis += GROUP_INFO_POLL_INTERVAL_MILLIS
if (currentGroup == null && !hasNotifiedStarted &&
elapsedMillis >= GROUP_FORMATION_TIMEOUT_MILLIS
) {
Log.e(TAG, "Group never formed within ${GROUP_FORMATION_TIMEOUT_MILLIS}ms")
failStartup("Hotspot failed to start. Please try again.")
return
}
requestGroupInfo()
handler.postDelayed(this, GROUP_INFO_POLL_INTERVAL_MILLIS)
}
}, GROUP_INFO_POLL_INTERVAL_MILLIS)
}
/**
* Request current group information.
*/
@SuppressLint("MissingPermission")
private fun requestGroupInfo() {
val ch = channel ?: return
try {
wifiP2pManager?.requestGroupInfo(ch) { group ->
// A reply arriving after the hotspot stopped must not revive any
// state the stop just cleared.
if (channel !== ch) return@requestGroupInfo
reconcileGroupOwnership(group)
if (group == null) {
Log.w(TAG, "requestGroupInfo returned null group")
return@requestGroupInfo
}
if (!isOurHostedGroup(group)) {
// Someone else's group is on the radio. Reading anything from it
// — its name, its credentials, its client count — would report
// another app's session as our hotspot, and recording its name
// would let the next start remove it without asking.
Log.w(
TAG,
"Observed group '${group.networkName}' is not the one we created"
)
return@requestGroupInfo
}
currentGroup = group
// Update saved credentials if using system-generated ones
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
savedSsid = group.networkName
savedPassword = group.passphrase
}
// Authoritative name straight from the framework, for the group we
// just confirmed is ours.
group.networkName?.let {
hostedGroupName = it
ownedGroupName = it
}
// Notify callback on FIRST successful group info retrieval
if (!hasNotifiedStarted) {
hasNotifiedStarted = true
Log.d(TAG, "Group info received, notifying callback")
callback?.onHotspotStarted()
} else {
// Subsequent updates
callback?.onConnectionInfoUpdated(getConnectionInfo())
}
}
} catch (e: SecurityException) {
Log.e(TAG, "Wi-Fi permission was revoked while reading group info", e)
failStartup("A required Wi-Fi or local network permission was revoked. Grant it and try again.")
}
}
/**
* Is this snapshot the group this session created?
*
* `isGroupOwner` cannot answer that on its own: it reports that *this device*
* hosts the group, which is equally true of an autonomous group another app
* created here. Above Q we chose the network name, so it identifies our group
* exactly. Below Q the framework names it, and the first snapshot after our own
* createGroup succeeded is the only evidence available after that the name is
* fixed, and a group answering to a different one is not ours.
*/
private fun isOurHostedGroup(group: WifiP2pGroup): Boolean {
if (!group.isGroupOwner) return false
val name = group.networkName ?: return false
hostedGroupName?.let { return name == it }
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
name == savedSsid
} else {
true
}
}
/**
* Keep [createdGroup] honest about what is actually on the framework.
*
* Our group can disappear without us Wi-Fi toggled, another app issuing its own
* device-scoped removeGroup(), a driver reset and another app can then create
* one in its place. Believing the group present is still ours would make stop
* remove that replacement, the exact disruption consent exists to prevent.
*
* Reconciled only once the framework has named our group: before that a null
* snapshot means the group is still forming, not that it is gone. Losing the flag
* to a transient null is safe in a way that keeping it is not the group we
* created is then left behind, and the next start recognises it by name and
* removes it silently.
*/
private fun reconcileGroupOwnership(group: WifiP2pGroup?) {
val expectedName = hostedGroupName ?: if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
) {
savedSsid
} else {
null
} ?: return
// A null snapshot is normal before the configured group appears. A non-null
// group with a different name is positive evidence that ours was replaced.
if (hostedGroupName == null && group == null) return
val stillOurs = group != null && isOurHostedGroup(group)
if (createdGroup && !stillOurs) {
Log.w(TAG, "Group '$expectedName' is no longer ours; leaving what is present alone")
}
createdGroup = stillOurs
}
/**
* Acquire WakeLock and WifiLock to keep hotspot active.
*/
private fun acquireLocks() {
try {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"BitChat:HotspotWakeLock"
)
wakeLock?.acquire(30 * 60 * 1000L)
val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager
val lockType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF
} else {
android.net.wifi.WifiManager.WIFI_MODE_FULL
}
wifiLock = wifiManager.createWifiLock(lockType, "BitChat:HotspotWifiLock")
wifiLock?.acquire()
Log.d(TAG, "Acquired WakeLock and WifiLock")
} catch (e: Exception) {
Log.e(TAG, "Error acquiring locks", e)
}
}
/**
* Release WakeLock and WifiLock.
*/
private fun releaseLocks() {
try {
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
wakeLock = null
wifiLock?.let {
if (it.isHeld) {
it.release()
}
}
wifiLock = null
Log.d(TAG, "Released WakeLock and WifiLock")
} catch (e: Exception) {
Log.e(TAG, "Error releasing locks", e)
}
}
/**
* Get the IP address of the P2P access point.
* Looks for network interface starting with "p2p".
*/
private fun getAccessPointAddress(): String? {
try {
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val iface = interfaces.nextElement()
if (iface.name.startsWith("p2p")) {
val addresses = iface.interfaceAddresses
for (addr in addresses) {
val address = addr.address
// IPv4 only (4 bytes)
if (address.address.size == 4) {
return address.hostAddress
}
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error getting access point address", e)
}
return null
}
/**
* Generate random SSID.
* Format: DIRECT-BC-XXXXXXXX
*/
private fun generateSsid(): String {
val suffix = (1..SSID_SUFFIX_LENGTH)
.map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] }
.joinToString("")
return "${HotspotStartupPolicy.SSID_PREFIX}$suffix"
}
/**
* Generate random password.
* 16 characters, excluding confusing characters.
*/
private fun generatePassword(): String {
return (1..PASSWORD_LENGTH)
.map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] }
.joinToString("")
}
/**
* Connection information for the hotspot.
*/
data class ConnectionInfo(
val ssid: String,
val password: String,
val ipAddress: String,
val connectedPeers: Int
)
/**
* Callback interface for hotspot events.
*/
interface HotspotCallback {
fun onHotspotStarted()
fun onConnectionInfoUpdated(info: ConnectionInfo?)
/**
* A Wi-Fi Direct group belonging to another app is active and the caller has
* not confirmed replacing it. Ask the user, then retry with this name as
* `confirmedReplacementName` if they accept. Nothing was disturbed.
*/
fun onExistingGroupConflict(groupName: String)
fun onError(message: String)
}
}

View File

@ -0,0 +1,35 @@
package com.bitchat.android.hotspot
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
internal object HotspotPermissions {
const val ANDROID_17_API_LEVEL = 37
@SuppressLint("InlinedApi")
fun requiredForSdk(sdkInt: Int = Build.VERSION.SDK_INT): List<String> {
return when {
sdkInt >= ANDROID_17_API_LEVEL -> listOf(
Manifest.permission.NEARBY_WIFI_DEVICES,
Manifest.permission.ACCESS_LOCAL_NETWORK
)
sdkInt >= Build.VERSION_CODES.TIRAMISU -> listOf(
Manifest.permission.NEARBY_WIFI_DEVICES
)
sdkInt >= Build.VERSION_CODES.Q -> listOf(
Manifest.permission.ACCESS_FINE_LOCATION
)
else -> emptyList()
}
}
fun missingFrom(context: Context): List<String> {
return requiredForSdk().filter { permission ->
ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED
}
}
}

Some files were not shown because too many files have changed in this diff Show More