mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge branch 'main' into main
This commit is contained in:
commit
19be706612
260
.agents/skills/android-readme-screenshot-studio/SKILL.md
Normal file
260
.agents/skills/android-readme-screenshot-studio/SKILL.md
Normal 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.
|
||||
@ -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."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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.
|
||||
@ -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)
|
||||
|
||||
255
.agents/skills/android-ui-visual-review/SKILL.md
Normal file
255
.agents/skills/android-ui-visual-review/SKILL.md
Normal 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
|
||||
|
||||
@ -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."
|
||||
]
|
||||
}
|
||||
@ -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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
Fixture disclosure: FIXTURE_DESCRIPTION_OR_NONE.
|
||||
|
||||
41
.agents/skills/android-ui-visual-review/evals/evals.json
Normal file
41
.agents/skills/android-ui-visual-review/evals/evals.json
Normal 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."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
201
.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh
Executable file
201
.agents/skills/android-ui-visual-review/scripts/create_review_worktree.sh
Executable 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"
|
||||
217
.agents/skills/android-ui-visual-review/scripts/upload_pr_images.py
Executable file
217
.agents/skills/android-ui-visual-review/scripts/upload_pr_images.py
Executable 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)
|
||||
|
||||
168
.agents/skills/android-ui-visual-review/scripts/validate_capture_manifest.py
Executable file
168
.agents/skills/android-ui-visual-review/scripts/validate_capture_manifest.py
Executable 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)
|
||||
|
||||
224
.agents/skills/mesh-lab/SKILL.md
Normal file
224
.agents/skills/mesh-lab/SKILL.md
Normal 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.
|
||||
41
.agents/skills/mesh-lab/evals/evals.json
Normal file
41
.agents/skills/mesh-lab/evals/evals.json
Normal 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
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
.git
|
||||
.gradle
|
||||
.reproducible-build
|
||||
**/build
|
||||
local.properties
|
||||
tools/arti-build/.arti-source
|
||||
184
.github/workflows/android-build.yml
vendored
184
.github/workflows/android-build.yml
vendored
@ -3,102 +3,140 @@ name: Android CI
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "main", "develop" ]
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [ "main", "develop" ]
|
||||
branches: [main, develop]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SETUP_JAVA_VERSION: 21.0.11+10.0.LTS
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Test & Lint
|
||||
runs-on: ubuntu-latest
|
||||
name: Test and lint
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
- name: Set up pinned JDK
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ env.SETUP_JAVA_VERSION }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v3
|
||||
- name: Set up and validate Gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Verify native library inputs
|
||||
run: tools/arti-build/verify-checksums.sh
|
||||
|
||||
- name: Cache Gradle packages
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
- name: Run unit tests
|
||||
run: ./gradlew testDebugUnitTest
|
||||
|
||||
- name: Run unit tests
|
||||
run: ./gradlew testDebugUnitTest
|
||||
- name: Run lint
|
||||
run: ./gradlew lintDebug
|
||||
|
||||
- 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 test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@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"
|
||||
|
||||
- name: Upload lint results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: lint-results
|
||||
path: '**/build/reports/lint-results-*.html'
|
||||
build-debug:
|
||||
name: Build debug APK
|
||||
runs-on: ubuntu-24.04
|
||||
needs: verify
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.variant }}
|
||||
runs-on: ubuntu-latest
|
||||
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:
|
||||
variant: [Debug, Release]
|
||||
replica: [a, b]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
- name: Set up pinned JDK
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ env.SETUP_JAVA_VERSION }}
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v3
|
||||
- name: Set up and validate Gradle
|
||||
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
|
||||
with:
|
||||
cache-disabled: true
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
- name: Build canonical unsigned release
|
||||
run: tools/reproducible-builds/build-in-container.sh "$RUNNER_TEMP/release-${{ matrix.replica }}"
|
||||
|
||||
- name: Cache Gradle packages
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
- 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
|
||||
|
||||
- name: Build ${{ matrix.variant }} APK
|
||||
run: ./gradlew assemble${{ matrix.variant }}
|
||||
compare-reproducible-builds:
|
||||
name: Compare release bytes
|
||||
runs-on: ubuntu-24.04
|
||||
needs: reproducible-build
|
||||
|
||||
- name: Upload ${{ matrix.variant }} APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.variant }}-apk
|
||||
path: app/build/outputs/apk/**/*.apk
|
||||
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"
|
||||
|
||||
6
.github/workflows/fetch-georelays.yml
vendored
6
.github/workflows/fetch-georelays.yml
vendored
@ -10,11 +10,11 @@ permissions:
|
||||
|
||||
jobs:
|
||||
update-relay-data:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@ -37,4 +37,4 @@ jobs:
|
||||
git commit -m "Automated update of relay data - $(date -u)"
|
||||
git push
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
180
.github/workflows/release.yml
vendored
180
.github/workflows/release.yml
vendored
@ -2,106 +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 Release APKs (with architecture splits)
|
||||
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 -exec ls -lh {} \;
|
||||
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 APKs for GitHub Release
|
||||
run: |
|
||||
cd app/build/outputs/apk/release
|
||||
[ -f "app-arm64-v8a-release-unsigned.apk" ] && mv app-arm64-v8a-release-unsigned.apk bitchat-android-arm64.apk
|
||||
[ -f "app-x86_64-release-unsigned.apk" ] && mv app-x86_64-release-unsigned.apk bitchat-android-x86_64.apk
|
||||
[ -f "app-universal-release-unsigned.apk" ] && mv app-universal-release-unsigned.apk bitchat-android-universal.apk
|
||||
|
||||
- name: DEBUG
|
||||
run: |
|
||||
set -x
|
||||
pwd
|
||||
ls -all
|
||||
cd app/build/outputs/
|
||||
ls -all
|
||||
tree || ls -R
|
||||
|
||||
# Optional: Sign APKs (uncomment and configure secrets when ready)
|
||||
# - name: Sign APKs
|
||||
# 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 APKs as artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Checkout tagged source
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
with:
|
||||
name: bitchat-android-release-${{ 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 release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Checkout verification script
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
|
||||
with:
|
||||
name: bitchat-android-release-${{ github.ref_name }}
|
||||
path: release
|
||||
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: |
|
||||
release/bitchat-android-arm64.apk
|
||||
release/bitchat-android-x86_64.apk
|
||||
release/bitchat-android-universal.apk
|
||||
name: Release ${{ github.ref_name }}
|
||||
body: |
|
||||
**bitchat-android-arm64.apk** - ARM64 (most phones)
|
||||
**bitchat-android-x86_64.apk** - x86_64 (Chromebooks, tablets)
|
||||
**bitchat-android-universal.apk** - All architectures (fallback)
|
||||
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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -57,6 +57,7 @@ gen/
|
||||
*~
|
||||
*.swp
|
||||
*.lock
|
||||
!tools/arti-build/Cargo.lock
|
||||
.goosehints
|
||||
|
||||
# Google services
|
||||
@ -69,6 +70,7 @@ google-services.json
|
||||
# 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
1
.java-version
Normal file
@ -0,0 +1 @@
|
||||
21.0.11
|
||||
176
AGENTS.md
176
AGENTS.md
@ -1,79 +1,127 @@
|
||||
# Bitchat Android - Agent Guide
|
||||
# Repository Guidelines
|
||||
|
||||
This document provides context, architectural insights, and development standards for AI agents working on the Bitchat Android codebase.
|
||||
## Privacy & External Disclosure (Mandatory)
|
||||
|
||||
## 1. Project Overview
|
||||
**Bitchat** is a decentralized, off-grid communication application focused on privacy and censorship resistance. It utilizes mesh networking (primarily Bluetooth LE and Tor/Arti) to enable peer-to-peer messaging without centralized servers.
|
||||
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.
|
||||
|
||||
**Key Technologies:**
|
||||
- **Language:** Kotlin (JVM Target 1.8)
|
||||
- **UI Framework:** Jetpack Compose (Material 3)
|
||||
- **Asynchronous:** Kotlin Coroutines & Flow
|
||||
- **Networking:** Bluetooth Low Energy (BLE), Tor (Arti Rust bridge), OkHttp
|
||||
- **Architecture:** MVVM with Clean Architecture principles
|
||||
- **Build System:** Gradle (Kotlin DSL)
|
||||
- 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.
|
||||
|
||||
## 2. Architecture & Directory Structure
|
||||
The application follows a clean architecture pattern, heavily modularized by feature within the `app` module.
|
||||
## Project Structure & Architecture
|
||||
|
||||
**Root Package:** `com.bitchat.android`
|
||||
`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/`.
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `ui/` | **Presentation Layer**: Jetpack Compose screens, themes, and ViewModels. |
|
||||
| `service/` | **Core Service**: Contains `MeshForegroundService`, managing persistent background connectivity. |
|
||||
| `mesh/` | **Mesh Networking**: Logic for peer discovery, advertising, and message routing. |
|
||||
| `protocol/` | **Wire Protocol**: Definitions of messages exchanged between peers. |
|
||||
| `crypto/` | **Security**: Cryptographic primitives and key management. |
|
||||
| `noise/` | **Encryption**: Implementation of the Noise Protocol Framework for secure channels. |
|
||||
| `identity/` | **User Identity**: Management of user profiles and public/private keys. |
|
||||
| `features/` | **App Features**: Sub-modules for `voice`, `file`, and `media` handling. |
|
||||
| `nostr/` | **Relay Integration**: Logic for Nostr protocol integration and relay management. |
|
||||
| `geohash/` | **Location**: Utilities for location-based features and geohashing. |
|
||||
| `net/` | **Networking**: General network utilities and abstractions. |
|
||||
`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.
|
||||
|
||||
## 3. Key Components
|
||||
## Build, Test & Development Commands
|
||||
|
||||
### UI Layer (Jetpack Compose)
|
||||
- **Activity**: Single-Activity architecture (`MainActivity.kt`).
|
||||
- **Navigation**: Jetpack Compose Navigation.
|
||||
- **State Management**: `ViewModel` exposing `StateFlow` to Composables.
|
||||
- **Theme**: Custom theme definitions in `ui/theme`.
|
||||
Use JDK 21 and the Android SDK versions in `gradle/libs.versions.toml`.
|
||||
|
||||
### Networking & Connectivity
|
||||
- **MeshForegroundService**: The critical component that keeps the mesh network alive. It manages the lifecycle of BLE scanning/advertising and other transport layers.
|
||||
- **BLE Stack**: Located in `mesh/` and `net/`, handles the intricacies of Android Bluetooth interactions.
|
||||
- **Tor/Arti**: Integrated via JNI (`jniLibs`) to provide anonymous internet routing where available.
|
||||
```sh
|
||||
./gradlew :app:assembleDebug :wear:assembleDebug
|
||||
./gradlew testDebugUnitTest lintDebug
|
||||
./gradlew connectedAndroidTest
|
||||
./gradlew clientRewriteContractTest
|
||||
tools/arti-build/verify-checksums.sh
|
||||
```
|
||||
|
||||
## 4. Development Standards
|
||||
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.
|
||||
|
||||
### Code Style
|
||||
- **Kotlin**: Adhere to official Kotlin coding conventions.
|
||||
- **Compose**: Use functional components. Hoist state to ViewModels where possible.
|
||||
- **Coroutines**: Use `suspend` functions for all I/O operations. strictly avoid blocking the main thread.
|
||||
- **Naming**: Clear, descriptive names. Follow standard Android naming patterns (e.g., `*ViewModel`, `*Repository`, `*Screen`).
|
||||
## Coding Style & Naming
|
||||
|
||||
### Testing
|
||||
- **Unit Tests**: Located in `app/src/test/`. Use for business logic, protocols, and utility testing.
|
||||
- **Instrumented Tests**: Located in `app/src/androidTest/`. Use for UI and permission integration testing.
|
||||
- **Device Mesh Tests (ADB test hooks)**: Two-physical-device scenarios driven over ADB, **kept separate from Gradle/CI** — run them manually when changing mesh/crypto/transfer code. A debug-only broadcast receiver (`app/src/debug/java/com/bitchat/android/testhook/`, never in release builds) exposes mesh operations (scan, connect, Noise handshake, DMs, broadcast, files, raw packet injection) via `am broadcast -a com.bitchat.droid.TEST_HOOK`; the host orchestrator is `tools/release_gate/mesh_lab.py`. Full guide: `docs/release-gate-runbook.md` appendix "mesh lab".
|
||||
- Prereqs: `adb` on PATH, Python 3.10+, two devices with USB debugging, **both unlocked with screen on** (locked/dozing → POWER_SAVER → flaky timing).
|
||||
- Setup: `./gradlew assembleDebug && python3 tools/release_gate/mesh_lab.py setup --serial-a <s1> --serial-b <s2> --apk app/build/outputs/apk/debug/app-arm64-v8a-debug.apk`
|
||||
- Run: `python3 tools/release_gate/mesh_lab.py scenario all --serial-a <s1> --serial-b <s2> --out /tmp/meshlab-evidence`
|
||||
- Scenarios: `dm`, `broadcast`, `file`, `file_oversize`, `file_private`, `raw`, `session_recovery`, `identity_reset`, `all`. Ad-hoc: `... cmd --serial <s> state`.
|
||||
- **Execution**:
|
||||
- Unit: `./gradlew test`
|
||||
- Instrumented: `./gradlew connectedAndroidTest`
|
||||
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.
|
||||
|
||||
## 5. Critical Constraints & Gotchas
|
||||
1. **Permissions**: The app relies heavily on dangerous runtime permissions (Location, Bluetooth Scan/Connect/Advertise, Audio Recording). Always verify permission handling patterns in `MainActivity` or permission wrappers before adding new hardware features.
|
||||
2. **Hardware Dependency**: Features like BLE are difficult to emulate. When writing code for these, focus on robust error handling and defensive programming as hardware behavior can be flaky.
|
||||
3. **Background Limits**: Android enforces strict background execution limits. Network operations intended to persist must be tied to the `MeshForegroundService`.
|
||||
Protocol and security changes must remain fail-closed and cross-client
|
||||
compatible. Update the relevant specification and golden-vector tests.
|
||||
|
||||
## 6. Common Tasks
|
||||
- **Build Debug APK**: `./gradlew assembleDebug`
|
||||
- **Lint Check**: `./gradlew lint`
|
||||
- **Clean Build**: `./gradlew clean`
|
||||
## Testing & Physical Mesh Lab
|
||||
|
||||
---
|
||||
*Note: This file is intended to assist AI agents in navigating and modifying the codebase efficiently. Always verify context by reading the actual files before making changes.*
|
||||
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.
|
||||
|
||||
156
PRIVACY_POLICY.md
Normal file
156
PRIVACY_POLICY.md
Normal file
@ -0,0 +1,156 @@
|
||||
# bitchat Privacy Policy
|
||||
|
||||
*Last updated: January 2025*
|
||||
|
||||
## Our Commitment
|
||||
|
||||
bitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy.
|
||||
|
||||
## Summary
|
||||
|
||||
**WE DO NOT COLLECT ANY INFORMATION.**
|
||||
|
||||
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
||||
- **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
|
||||
|
||||
## What Information bitchat Stores
|
||||
|
||||
### On Your Device Only
|
||||
|
||||
1. **Identity Key**
|
||||
- A cryptographic key generated on first launch
|
||||
- Stored locally in your device's secure storage
|
||||
- Allows you to maintain "favorite" relationships across app restarts
|
||||
- Never leaves your device
|
||||
|
||||
2. **Nickname**
|
||||
- The display name you choose (or auto-generated)
|
||||
- Stored only on your device
|
||||
- Shared with peers you communicate with
|
||||
|
||||
3. **Message History** (if enabled)
|
||||
- When room owners enable retention, messages are saved locally
|
||||
- Stored encrypted on your device
|
||||
- You can delete this at any time
|
||||
|
||||
4. **Favorite Peers**
|
||||
- Public keys of peers you mark as favorites
|
||||
- Stored only on your device
|
||||
- Allows you to recognize these peers in future sessions
|
||||
|
||||
### Temporary Session Data
|
||||
|
||||
During each session, bitchat temporarily maintains:
|
||||
- Active peer connections (forgotten when app closes)
|
||||
- Routing information for message delivery
|
||||
- Cached messages for offline peers (12 hours max)
|
||||
|
||||
## What Information is Shared
|
||||
|
||||
### With Other bitchat Users
|
||||
|
||||
When you use bitchat, nearby peers can see:
|
||||
- Your chosen nickname
|
||||
- Your ephemeral public key (changes each session)
|
||||
- Messages you send to public rooms or directly to them
|
||||
- Your approximate Bluetooth signal strength (for connection quality)
|
||||
|
||||
### With Room Members
|
||||
|
||||
When you join a password-protected room:
|
||||
- Your messages are visible to others with the password
|
||||
- Your nickname appears in the member list
|
||||
- Room owners can see you've joined
|
||||
|
||||
## What We DON'T Do
|
||||
|
||||
bitchat **never**:
|
||||
- Collects personal information
|
||||
- Collects location history
|
||||
- Transmits any data to us (the developers)
|
||||
- Stores data on servers
|
||||
- Shares data with third parties
|
||||
- Uses analytics or telemetry
|
||||
- Creates user profiles
|
||||
- Requires registration
|
||||
|
||||
## Encryption
|
||||
|
||||
All private messages use end-to-end encryption:
|
||||
- **X25519** for key exchange
|
||||
- **AES-256-GCM** for message encryption
|
||||
- **Ed25519** for digital signatures
|
||||
- **Argon2id** for password-protected rooms
|
||||
|
||||
## Your Rights
|
||||
|
||||
You have complete control:
|
||||
- **Delete Everything**: Triple-tap the logo to instantly wipe all data
|
||||
- **Leave Anytime**: Close the app and your presence disappears
|
||||
- **No Account**: Nothing to delete from servers because there are none
|
||||
- **Portability**: Your data never leaves your device unless you export it
|
||||
|
||||
## Location Data & Permissions
|
||||
|
||||
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
|
||||
|
||||
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
|
||||
|
||||
## Data Retention
|
||||
|
||||
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
|
||||
- **Identity Key**: Persists until you delete the app
|
||||
- **Favorites**: Persist until you remove them or delete the app
|
||||
- **Everything Else**: Exists only during active sessions
|
||||
|
||||
## Security Measures
|
||||
|
||||
- All communication is encrypted
|
||||
- No data transmitted to servers (there are none)
|
||||
- Open source code for public audit
|
||||
- Regular security updates
|
||||
- Cryptographic signatures prevent tampering
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
If we update this policy:
|
||||
- The "Last updated" date will change
|
||||
- The updated policy will be included in the app
|
||||
- No retroactive changes can affect data (since we don't collect any)
|
||||
|
||||
## Contact
|
||||
|
||||
bitchat is an open source project. For privacy questions:
|
||||
- Review our code: https://github.com/yourusername/bitchat
|
||||
- Open an issue on GitHub
|
||||
- Join the discussion in public rooms
|
||||
|
||||
## Philosophy
|
||||
|
||||
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely.
|
||||
|
||||
---
|
||||
|
||||
*This policy is released into the public domain under The Unlicense, just like bitchat itself.*
|
||||
19
README.md
19
README.md
@ -12,6 +12,19 @@ This is the Android implementation of bitchat, fully protocol-compatible with th
|
||||
|
||||
[<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)
|
||||
|
||||
## See it in action
|
||||
|
||||
<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
|
||||
|
||||
This project is released into the public domain. See the [LICENSE](LICENSE.md) file for details.
|
||||
@ -70,6 +83,12 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
The app requests Bluetooth, location (required for BLE scanning), and notification permissions at runtime.
|
||||
|
||||
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.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
|
||||
@ -24,13 +24,14 @@ require(
|
||||
android {
|
||||
namespace = "com.bitchat.android"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
buildToolsVersion = libs.versions.buildTools.get()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.bitchat.droid"
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = 36
|
||||
versionName = "1.7.5"
|
||||
versionCode = 38
|
||||
versionName = "2.0.1"
|
||||
buildConfigField(
|
||||
"String",
|
||||
"GITHUB_RELEASE_CERT_SHA256",
|
||||
@ -64,6 +65,11 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -105,7 +111,15 @@ 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)
|
||||
}
|
||||
|
||||
464
app/gradle.lockfile
Normal file
464
app/gradle.lockfile
Normal 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
|
||||
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
@ -17,6 +17,13 @@
|
||||
-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 { *; }
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,15 @@ 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
|
||||
@ -16,6 +24,7 @@ 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
|
||||
@ -62,11 +71,26 @@ object TestHookDriver {
|
||||
"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")
|
||||
@ -131,6 +155,21 @@ object TestHookDriver {
|
||||
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")
|
||||
@ -261,6 +300,68 @@ object TestHookDriver {
|
||||
.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 {
|
||||
@ -283,6 +384,10 @@ object TestHookDriver {
|
||||
)
|
||||
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
|
||||
@ -291,7 +396,14 @@ object TestHookDriver {
|
||||
TransferProgressManager.events.first { it.transferId == transferId && it.completed }
|
||||
}
|
||||
delay(50)
|
||||
val sendError = dispatchFileSend(context, intent, mesh, peerID, packet, transferId)
|
||||
val sendError = dispatchFileSend(
|
||||
context,
|
||||
intent,
|
||||
mesh,
|
||||
recipient?.meshPeerID,
|
||||
packet,
|
||||
transferId
|
||||
)
|
||||
if (sendError != null) {
|
||||
completion.cancel()
|
||||
return@coroutineScope sendError.put("cmd", "file_send")
|
||||
@ -307,7 +419,8 @@ object TestHookDriver {
|
||||
.put("sent", event.sent)
|
||||
.put("total", event.total)
|
||||
.put("bytes", content.size)
|
||||
.put("peer", peerID)
|
||||
.put("peer", recipient?.meshPeerID)
|
||||
.put("conversation", peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@ -386,6 +499,131 @@ object TestHookDriver {
|
||||
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 {
|
||||
|
||||
@ -80,11 +80,21 @@
|
||||
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"
|
||||
@ -127,15 +137,10 @@
|
||||
tools:ignore="DataExtractionRules">
|
||||
</service>
|
||||
|
||||
<!-- Listen for in-app broadcast when POST_NOTIFICATIONS is granted -->
|
||||
<receiver
|
||||
android:name=".service.NotificationPermissionChangedReceiver"
|
||||
android:name=".service.ConversationNotificationReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
android:exported="false" />
|
||||
|
||||
<!-- Auto-start mesh service after boot if enabled -->
|
||||
<receiver
|
||||
|
||||
@ -1,225 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
:root { --text: #333; }
|
||||
html, body, #map { height: 100%; margin: 0; padding: 0; background: #ffffff; }
|
||||
.leaflet-container { background: #ffffff; }
|
||||
.leaflet-div-icon { background: transparent; border: none; }
|
||||
.gh-label { background: transparent; border: none; pointer-events: none; filter: none; }
|
||||
.gh-text {
|
||||
color: #444444;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 2px #ffffff, 0 0 2px #ffffff, 0 0 2px #ffffff;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
.dark .gh-text {
|
||||
color: #dddddd;
|
||||
text-shadow: 0 0 2px #000000, 0 0 2px #000000, 0 0 2px #000000;
|
||||
}
|
||||
.gh-text-selected {
|
||||
color: #00C851 !important;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
// Minimal geohash (bounds/encode/adjacent)
|
||||
(function () {
|
||||
const base32 = "0123456789bcdefghjkmnpqrstuvwxyz";
|
||||
function bounds(geohash) {
|
||||
let evenBit = true; let latMin = -90, latMax = 90, lonMin = -180, lonMax = 180;
|
||||
geohash = geohash.toLowerCase();
|
||||
for (let i = 0; i < geohash.length; i++) {
|
||||
const idx = base32.indexOf(geohash.charAt(i));
|
||||
if (idx == -1) throw new Error("Invalid geohash");
|
||||
for (let n = 4; n >= 0; n--) {
|
||||
const bitN = (idx >> n) & 1;
|
||||
if (evenBit) { const lonMid = (lonMin + lonMax) / 2; if (bitN == 1) lonMin = lonMid; else lonMax = lonMid; }
|
||||
else { const latMid = (latMin + latMax) / 2; if (bitN == 1) latMin = latMid; else latMax = latMid; }
|
||||
evenBit = !evenBit;
|
||||
}
|
||||
}
|
||||
return { sw: { lat: latMin, lng: lonMin }, ne: { lat: latMax, lng: lonMax } };
|
||||
}
|
||||
function encode(lat, lon, precision) {
|
||||
let idx = 0, bit = 0, evenBit = true, hash = "";
|
||||
let latMin = -90, latMax = 90, lonMin = -180, lonMax = 180;
|
||||
while (hash.length < precision) {
|
||||
if (evenBit) { const lonMid = (lonMin + lonMax) / 2; if (lon >= lonMid) { idx = idx * 2 + 1; lonMin = lonMid; } else { idx = idx * 2; lonMax = lonMid; } }
|
||||
else { const latMid = (latMin + latMax) / 2; if (lat >= latMid) { idx = idx * 2 + 1; latMin = latMid; } else { idx = idx * 2; latMax = latMid; } }
|
||||
evenBit = !evenBit; if (++bit == 5) { hash += base32.charAt(idx); bit = 0; idx = 0; }
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
function adjacent(hash, dir) {
|
||||
const neighbour = { n:["p0r21436x8zb9dcf5h7kjnmqesgutwvy","bc01fg45238967deuvhjyznpkmstqrwx"], s:["14365h7k9dcfesgujnmqp0r2twvyx8zb","238967debc01fg45kmstqrwxuvhjyznp"], e:["bc01fg45238967deuvhjyznpkmstqrwx","p0r21436x8zb9dcf5h7kjnmqesgutwvy"], w:["238967debc01fg45kmstqrwxuvhjyznp","14365h7k9dcfesgujnmqp0r2twvyx8zb"] };
|
||||
const border = { n:["prxz","bcfguvyz"], s:["028b","0145hjnp"], e:["bcfguvyz","prxz"], w:["0145hjnp","028b"] };
|
||||
hash = hash.toLowerCase(); const lastCh = hash.slice(-1); let parent = hash.slice(0, -1); const type = hash.length % 2;
|
||||
if (border[dir][type].indexOf(lastCh) != -1 && parent != "") parent = adjacent(parent, dir);
|
||||
return parent + base32.charAt(neighbour[dir][type].indexOf(lastCh));
|
||||
}
|
||||
window.__geohash = { bounds, encode, adjacent };
|
||||
})();
|
||||
|
||||
const map = L.map("map", { zoomControl: true, minZoom: 2, maxZoom: 21 }).setView([0, 0], 3);
|
||||
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", { maxZoom: 21, attribution: "© OpenStreetMap © Carto", opacity: 1.0 }).addTo(map);
|
||||
|
||||
let selectedGeohash = "";
|
||||
let gridLayer = L.layerGroup().addTo(map);
|
||||
let pinnedPrecision = null;
|
||||
let outlineColor = "#00C851";
|
||||
|
||||
function getNeighbors(hash) {
|
||||
const neighbors = [];
|
||||
// N, S, E, W
|
||||
neighbors.push(window.__geohash.adjacent(hash, 'n'));
|
||||
neighbors.push(window.__geohash.adjacent(hash, 's'));
|
||||
neighbors.push(window.__geohash.adjacent(hash, 'e'));
|
||||
neighbors.push(window.__geohash.adjacent(hash, 'w'));
|
||||
// Diagonals
|
||||
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 'n'), 'e'));
|
||||
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 'n'), 'w'));
|
||||
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 's'), 'e'));
|
||||
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 's'), 'w'));
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
function pickPrecisionForViewport() {
|
||||
const c = map.getCenter();
|
||||
const minPx = 80;
|
||||
const maxPx = 240;
|
||||
let chosen = 1;
|
||||
let lastAboveMin = 1;
|
||||
for (let p = 1; p <= 12; p++) {
|
||||
const gh = window.__geohash.encode(c.lat, c.lng, p);
|
||||
const b = window.__geohash.bounds(gh);
|
||||
const pSw = map.latLngToLayerPoint([b.sw.lat, b.sw.lng]);
|
||||
const pNe = map.latLngToLayerPoint([b.ne.lat, b.ne.lng]);
|
||||
const cellPx = Math.min(Math.abs(pNe.x - pSw.x), Math.abs(pSw.y - pNe.y));
|
||||
if (cellPx >= minPx && cellPx <= maxPx) { chosen = p; break; }
|
||||
if (cellPx >= minPx) { lastAboveMin = p; }
|
||||
if (cellPx < minPx) { chosen = lastAboveMin; break; }
|
||||
if (p === 12) { chosen = 12; }
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
function notifySelection() {
|
||||
if (window.Android && window.Android.onGeohashChanged && selectedGeohash) {
|
||||
window.Android.onGeohashChanged(selectedGeohash);
|
||||
}
|
||||
}
|
||||
|
||||
function zoomForPrecision(p) {
|
||||
if (p <= 1) return 1; if (p === 2) return 2; if (p === 3) return 3; if (p === 4) return 4;
|
||||
if (p === 5) return 5; if (p === 6) return 7; if (p === 7) return 9; if (p === 8) return 11;
|
||||
if (p === 9) return 13; if (p === 10) return 15; if (p === 11) return 17;
|
||||
return 18;
|
||||
}
|
||||
|
||||
function updateOverlay() {
|
||||
gridLayer.clearLayers();
|
||||
const c = map.getCenter();
|
||||
const usePinned = pinnedPrecision !== null;
|
||||
const p = usePinned ? pinnedPrecision : pickPrecisionForViewport();
|
||||
selectedGeohash = window.__geohash.encode(c.lat, c.lng, p);
|
||||
notifySelection();
|
||||
|
||||
const centerBounds = window.__geohash.bounds(selectedGeohash);
|
||||
const centerLon = (centerBounds.sw.lng + centerBounds.ne.lng) / 2;
|
||||
const centerLat = (centerBounds.sw.lat + centerBounds.ne.lat) / 2;
|
||||
|
||||
const allHashes = [selectedGeohash, ...getNeighbors(selectedGeohash)];
|
||||
|
||||
const filteredHashes = allHashes.filter(gh => {
|
||||
if (!gh) return false;
|
||||
try {
|
||||
const b = window.__geohash.bounds(gh);
|
||||
const lon = (b.sw.lng + b.ne.lng) / 2;
|
||||
const lat = (b.sw.lat + b.ne.lat) / 2;
|
||||
if (Math.abs(lon - centerLon) > 180) return false; // anti-meridian wrap
|
||||
if (Math.abs(lat - centerLat) > 90) return false; // pole wrap
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
});
|
||||
|
||||
filteredHashes.forEach(gh => {
|
||||
const b = window.__geohash.bounds(gh);
|
||||
const sw = [b.sw.lat, b.sw.lng];
|
||||
const ne = [b.ne.lat, b.ne.lng];
|
||||
const isSelected = (gh === selectedGeohash);
|
||||
|
||||
const rect = L.rectangle([sw, ne], {
|
||||
color: isSelected ? outlineColor : '#cccccc',
|
||||
weight: isSelected ? 3 : 1,
|
||||
fillOpacity: 0.0,
|
||||
opacity: 0.9,
|
||||
interactive: false
|
||||
});
|
||||
gridLayer.addLayer(rect);
|
||||
|
||||
const center = [(b.sw.lat + b.ne.lat) / 2, (b.sw.lng + b.ne.lng) / 2];
|
||||
const labelClass = isSelected ? 'gh-text gh-text-selected' : 'gh-text';
|
||||
const label = L.marker(center, {
|
||||
icon: L.divIcon({
|
||||
className: 'gh-label',
|
||||
html: `<span class="${labelClass}">${gh}</span>`
|
||||
}),
|
||||
interactive: false
|
||||
});
|
||||
gridLayer.addLayer(label);
|
||||
});
|
||||
}
|
||||
|
||||
map.on("movestart", () => { pinnedPrecision = null; });
|
||||
map.on("zoomstart", () => { pinnedPrecision = null; });
|
||||
map.on("moveend", updateOverlay);
|
||||
map.on("zoomend", updateOverlay);
|
||||
|
||||
function setCenter(lat, lng) { map.setView([lat, lng], map.getZoom()); }
|
||||
function setPrecision(p) {
|
||||
const clamped = Math.max(1, Math.min(12, p|0));
|
||||
const targetZoom = zoomForPrecision(clamped);
|
||||
map.setZoom(targetZoom);
|
||||
}
|
||||
function focusGeohash(gh) {
|
||||
if (!gh || typeof gh !== 'string') return;
|
||||
const g = gh.toLowerCase();
|
||||
const b = window.__geohash.bounds(g);
|
||||
pinnedPrecision = g.length;
|
||||
map.fitBounds([[b.sw.lat, b.sw.lng],[b.ne.lat, b.ne.lng]], { animate: false, padding: [8,8] });
|
||||
selectedGeohash = g;
|
||||
}
|
||||
function getGeohash() { return selectedGeohash; }
|
||||
|
||||
// Android side will call this with 'dark' or 'light'
|
||||
function setMapTheme(theme) {
|
||||
document.body.className = theme;
|
||||
}
|
||||
|
||||
window.setCenter = setCenter;
|
||||
window.setPrecision = setPrecision;
|
||||
window.focusGeohash = focusGeohash;
|
||||
window.getGeohash = getGeohash;
|
||||
window.setMapTheme = setMapTheme;
|
||||
|
||||
function cleanup() {
|
||||
try { map.off(); } catch (_) {}
|
||||
try { gridLayer.clearLayers(); } catch (_) {}
|
||||
try { map.remove(); } catch (_) {}
|
||||
}
|
||||
window.cleanup = cleanup;
|
||||
|
||||
map.whenReady(updateOverlay);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,442 +1,400 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
bitchat.nostr1.com,40.7057,-74.0136
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
nostr.2b9t.xyz,34.0549,-118.243
|
||||
armada.sharegap.net,43.6532,-79.3832
|
||||
nostr.chaima.info,51.5072,-0.127586
|
||||
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
|
||||
ribo.eu.nostria.app:443,43.6532,-79.3832
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
relay.nostu.be,40.4167,-3.70329
|
||||
nostr.whitenode45.ddns.net,40.55,-74.4758
|
||||
nostr.carroarmato0.be:443,50.914,3.21378
|
||||
cdn.satellite.earth,40.8302,-74.1299
|
||||
relay2.veganostr.com,60.1699,24.9384
|
||||
relay.layer.systems:443,49.0291,8.35695
|
||||
relay0.gfcom.info,13.7653,100.647
|
||||
relay.mmwaves.de:443,48.8575,2.35138
|
||||
offchain.pub,39.1585,-94.5728
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
staging.yabu.me,35.6092,139.73
|
||||
nostr.overpay.com,29.7449,-95.5343
|
||||
bridge.tagomago.me,42.3601,-71.0589
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
strfry.bonsai.com,39.0438,-77.4874
|
||||
relay.sharegap.net,43.6532,-79.3832
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||
treuzkas.branruz.com,48.8575,2.35138
|
||||
relay-rpi.edufeed.org:443,49.4521,11.0767
|
||||
vault.iris.to:443,43.6532,-79.3832
|
||||
node.kommonzenze.de,49.4521,11.0767
|
||||
nostr.thalheim.io:443,60.1699,24.9384
|
||||
soloco.nl,43.6532,-79.3832
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
nostr-relay.zimage.com,34.0549,-118.243
|
||||
public.crostr.com:443,43.6532,-79.3832
|
||||
nostr.sathoarder.com:443,48.5734,7.75211
|
||||
relay.angor.io,48.1046,11.6002
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
relay.mwaters.net,50.9871,2.12554
|
||||
relay.staging.commonshub.brussels,49.4543,11.0746
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
nostr-2.21crypto.ch,47.5356,8.73209
|
||||
relay.kaleidoswap.com,50.8476,4.35717
|
||||
relay.libernet.app:443,43.6532,-79.3832
|
||||
relay.homeinhk.xyz,35.694,139.754
|
||||
relay.manneken.brussels,49.4543,11.0746
|
||||
nostr.spicyz.io:443,43.6532,-79.3832
|
||||
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||
ribo.us.nostria.app:443,43.6532,-79.3832
|
||||
relay.loveisbitcoin.com,43.6532,-79.3832
|
||||
relay.angor.io:443,48.1046,11.6002
|
||||
relay02.lnfi.network,35.6764,139.65
|
||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
||||
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
|
||||
nrs-01.darkcloudarcade.com,39.0997,-94.5786
|
||||
relay.endfiat.money:443,59.3327,18.0656
|
||||
relay.paulstephenborile.com,49.4543,11.0746
|
||||
rele.speyhard.fi,51.5072,-0.127586
|
||||
relay.froth.zone,60.1699,24.9384
|
||||
relay.nostr.blockhenge.com,39.0438,-77.4874
|
||||
nrl.ceskar.xyz,50.5145,16.0119
|
||||
rilo.nostria.app,43.6532,-79.3832
|
||||
nostr.overmind.lol:443,43.6532,-79.3832
|
||||
nostr.snowbla.de:443,50.4754,12.3683
|
||||
nostrrelay.taylorperron.com,45.5029,-73.5723
|
||||
chorus.pjv.me,45.5201,-122.99
|
||||
relay.nostr.place,43.6532,-79.3832
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
nostr.girino.org:443,43.6532,-79.3832
|
||||
relay.aarpia.com,37.3986,-121.964
|
||||
nostr.thalheim.io,60.1699,24.9384
|
||||
ec2.f7z.io,60.1699,24.9384
|
||||
relay.trotters.cc,43.6532,-79.3832
|
||||
relay.mccormick.cx:443,52.3563,4.95714
|
||||
relay.momostr.pink,43.6532,-79.3832
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
conduitl2.fly.dev,37.7648,-122.432
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
relay.veganostr.com,60.1699,24.9384
|
||||
relay.minibolt.info:443,43.6532,-79.3832
|
||||
adre.su,59.9311,30.3609
|
||||
bitcoinostr.duckdns.org,41.1976,1.11167
|
||||
nostr.computingcache.com:443,34.0356,-118.442
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
nostr.hekster.org:443,37.3986,-121.964
|
||||
nostr.88mph.life,52.1941,-2.21905
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
nostr.planix.org,43.6532,-79.3832
|
||||
relay.satsmarkt.club,52.6907,4.8181
|
||||
nostrcity-club.fly.dev:443,37.7648,-122.432
|
||||
aeon.libretechsystems.xyz,55.486,9.86577
|
||||
testnet.samt.st,43.6532,-79.3832
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
wot.sudocarlos.com,43.6532,-79.3832
|
||||
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
||||
shu01.shugur.net,21.4902,39.2246
|
||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay2.angor.io:443,48.1046,11.6002
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
directories-safe-motherboard-recipients.trycloudflare.com,43.6532,-79.3832
|
||||
wot.nostr.party,36.1659,-86.7844
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
nostr.wild-vibes.ts.net,48.8566,2.35222
|
||||
relay.nostr.com,50.1109,8.68213
|
||||
nostr.iskarion.ddns.net,43.3076,-2.95421
|
||||
relay-dev.satlantis.io,39.0438,-77.4874
|
||||
relay.sovereignresonance.org,48.9006,2.25929
|
||||
relay.nostrian-conquest.com,41.223,-111.974
|
||||
relay.aidatanorge.no,43.6532,-79.3832
|
||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||
relay.klabo.world,47.2343,-119.853
|
||||
nostr.data.haus:443,50.4754,12.3683
|
||||
testr.nymble.world,40.8054,-74.0241
|
||||
relay.inforsupports.com,43.6532,-79.3832
|
||||
relay.nostrmap.net:443,60.1699,24.9384
|
||||
nostr.stakey.net:443,52.3676,4.90414
|
||||
dev-relay.nostreon.com,60.1699,24.9384
|
||||
nostr.islandarea.net:443,35.4669,-97.6473
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
relay.bowlafterbowl.com,32.9483,-96.7299
|
||||
nostr.quali.chat:443,60.1699,24.9384
|
||||
relay.plebeian.market,50.1109,8.68213
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
r.0kb.io,32.789,-96.7989
|
||||
nostr.notribe.net:443,40.8302,-74.1299
|
||||
relay.getsafebox.app:443,43.6532,-79.3832
|
||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||
nostrelites.org,34.9582,-81.9907
|
||||
nostr.hoppe-relay.it.com,42.8864,-78.8784
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
nostr.christiansass.de,51.7634,7.8887
|
||||
relay.btcforplebs.com,43.6532,-79.3832
|
||||
nostr.tagomago.me,42.3601,-71.0589
|
||||
relayone.geektank.ai,39.0997,-94.5786
|
||||
relay.dreamith.to:443,43.6532,-79.3832
|
||||
nostr.liberty.fans,36.8767,-89.5879
|
||||
wot.makenomistakes.ca,43.7064,-79.3986
|
||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||
relay.ohstr.com,43.6532,-79.3832
|
||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
||||
nostr.ac,38.958,-77.3592
|
||||
ribo.us.nostria.app,43.6532,-79.3832
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
relay.chorus.community:443,48.5333,10.7
|
||||
relay.cypherflow.ai,48.8575,2.35138
|
||||
relay.agorist.space:443,52.3734,4.89406
|
||||
relay.nostrian-conquest.com:443,41.223,-111.974
|
||||
relay.keykeeper.world,40.7824,-74.0711
|
||||
relay.getvia.xyz,60.1699,24.9384
|
||||
relay.nuts.cash,52.3676,4.90414
|
||||
kotukonostr.onrender.com,37.7775,-122.397
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||
relay.fountain.fm:443,43.6532,-79.3832
|
||||
relay.fountain.fm,43.6532,-79.3832
|
||||
nostr-02.uid.ovh,50.9871,2.12554
|
||||
relay.lanavault.space:443,60.1699,24.9384
|
||||
nostr.carroarmato0.be,50.914,3.21378
|
||||
nexus.libernet.app:443,43.6532,-79.3832
|
||||
relay.artio.inf.unibe.ch,46.9501,7.43678
|
||||
blossom.gnostr.cloud,43.6532,-79.3832
|
||||
relay.binaryrobot.com,43.6532,-79.3832
|
||||
relay.earthly.city,34.1749,-118.54
|
||||
nostr.hifish.org,47.4244,8.57658
|
||||
offchain.pub:443,39.1585,-94.5728
|
||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
||||
strfry.openhoofd.nl:443,51.5717,3.70417
|
||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||
strfry.ymir.cloud,43.6532,-79.3832
|
||||
nostrbtc.com,43.6532,-79.3832
|
||||
relay.directsponsor.net,42.8864,-78.8784
|
||||
nostr2.girino.org,43.6532,-79.3832
|
||||
relay.sigit.io:443,50.4754,12.3683
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
antiprimal.net,43.6532,-79.3832
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
inbox.scuba323.com,40.8218,-74.45
|
||||
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
nostr.davenov.com,50.1109,8.68213
|
||||
relay.trotters.cc:443,43.6532,-79.3832
|
||||
nostr.plantroon.com:443,50.1013,8.62643
|
||||
relay.nostreon.com,60.1699,24.9384
|
||||
nostr.easycryptosend.it,43.6532,-79.3832
|
||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
nostr.purpura.cloud,43.6532,-79.3832
|
||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
||||
nostr.mifen.me,43.6532,-79.3832
|
||||
testnet-relay.samt.st:443,40.8302,-74.1299
|
||||
nostr.2b9t.xyz:443,34.0549,-118.243
|
||||
relay.wavlake.com:443,41.2619,-95.8608
|
||||
relay.wisp.talk:443,49.4543,11.0746
|
||||
relay-dev.satlantis.io:443,39.0438,-77.4874
|
||||
relay.satlantis.io,39.0438,-77.4874
|
||||
relay.staging.plebeian.market,51.5072,-0.127586
|
||||
relay.openfarmtools.org,60.1699,24.9384
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
relay.binaryrobot.com:443,43.6532,-79.3832
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
relay.illuminodes.com,43.6532,-79.3832
|
||||
relay.liberbitworld.org,43.6532,-79.3832
|
||||
relay.olas.app:443,60.1699,24.9384
|
||||
no.str.cr,8.96171,-83.5246
|
||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||
wot.rejecttheframe.xyz,43.6532,-79.3832
|
||||
relay.nostriot.com:443,41.5695,-83.9786
|
||||
nostr.plantroon.com,50.1013,8.62643
|
||||
nostr-01.uid.ovh,50.9871,2.12554
|
||||
relay.openresist.com:443,43.6532,-79.3832
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.internationalright-wing.org,-22.5022,-48.7114
|
||||
nostr.myshosholoza.co.za:443,52.3676,4.90414
|
||||
nostr.pbfs.io:443,50.4754,12.3683
|
||||
21milionidinostr.duckdns.org,41.8967,12.4822
|
||||
nostr.4rs.nl,49.0291,8.35696
|
||||
relay.lanavault.space,60.1699,24.9384
|
||||
relay.mostr.pub,43.6532,-79.3832
|
||||
relay.nostar.org,43.6532,-79.3832
|
||||
nostr.mom,50.4754,12.3683
|
||||
relay.decentralia.fr,48.122,11.589
|
||||
relay.agentry.com,42.8864,-78.8784
|
||||
relay2.angor.io,48.1046,11.6002
|
||||
slick.mjex.me,39.0418,-77.4744
|
||||
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
|
||||
relay.vrtmrz.net:443,43.6532,-79.3832
|
||||
relay.beginningend.com,35.2227,-97.4786
|
||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
||||
relay.underorion.se,50.1109,8.68213
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
relay.qstr.app,51.5072,-0.127586
|
||||
relay.cyberguy.fyi,52.6907,4.8181
|
||||
strfry.bonsai.com:443,39.0438,-77.4874
|
||||
relayone.soundhsa.com:443,39.0997,-94.5786
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
relay.npubhaus.com,43.6532,-79.3832
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
relay.mitchelltribe.com:443,39.0438,-77.4874
|
||||
relay.44billion.net,43.6532,-79.3832
|
||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||
articles.layer3.news:443,37.3387,-121.885
|
||||
nostr.sovereignservices.xyz,43.6532,-79.3832
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
nostr-relay.amethyst.name,39.0067,-77.4291
|
||||
0x-nostr-relay.fly.dev,37.7648,-122.432
|
||||
relay.ohstr.com:443,43.6532,-79.3832
|
||||
00f2e774.relay.dev.thunderegg.us,39.0438,-77.4874
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
purplerelay.com:443,43.6532,-79.3832
|
||||
nostr-pr02.redscrypt.org,52.3676,4.90414
|
||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||
kasztanowa.bieda.it,43.6532,-79.3832
|
||||
relay.flashapp.me,43.6548,-79.3885
|
||||
relay.typedcypher.com,51.5072,-0.127586
|
||||
nostr.bond,50.1109,8.68213
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
nexus.libernet.app,43.6532,-79.3832
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
schnorr.me,43.6532,-79.3832
|
||||
relay.mostro.network:443,40.8302,-74.1299
|
||||
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||
relay.chorus.community,48.5333,10.7
|
||||
blossom.gnostr.cloud:443,43.6532,-79.3832
|
||||
syb.lol:443,34.0549,-118.243
|
||||
relay.dyne.org,49.0291,8.35705
|
||||
btc.klendazu.com,41.2861,1.24993
|
||||
wot.nostr.place,43.6532,-79.3832
|
||||
relay.openresist.com,43.6532,-79.3832
|
||||
rilo.nostria.app:443,43.6532,-79.3832
|
||||
no.str.cr:443,8.96171,-83.5246
|
||||
relay.mostr.pub:443,43.6532,-79.3832
|
||||
relay.edufeed.org:443,49.4521,11.0767
|
||||
nostr.debate.report,50.1109,8.68213
|
||||
relay.satmaxt.xyz:443,43.6532,-79.3832
|
||||
relay.artx.market:443,43.6548,-79.3885
|
||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||
relay.novospes.com,43.6532,-79.3832
|
||||
relay.nostr-check.me,43.6532,-79.3832
|
||||
nostr.computingcache.com,34.0356,-118.442
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
relay.fckstate.net,59.3293,18.0686
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
relay.bornheimer.app,51.5072,-0.127586
|
||||
relay.guggero.org,46.5971,9.59652
|
||||
relay01.lnfi.network,35.6764,139.65
|
||||
wot.shaving.kiwi,43.6532,-79.3832
|
||||
nostr.twinkle.lol,51.902,7.6657
|
||||
relay.edufeed.org,49.4521,11.0767
|
||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
nostr.hifish.org:443,47.4244,8.57658
|
||||
relay.cypherflow.ai:443,48.8575,2.35138
|
||||
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
|
||||
nostr.na.social:443,43.6532,-79.3832
|
||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||
relay.mypathtofire.de,42.8864,-78.8784
|
||||
public.crostr.com,43.6532,-79.3832
|
||||
relay.olas.app,60.1699,24.9384
|
||||
relay.agora.social,50.7383,15.0648
|
||||
ribo.nostria.app,43.6532,-79.3832
|
||||
relay.lab.rytswd.com,49.4543,11.0746
|
||||
relay.ditto.pub:443,43.6532,-79.3832
|
||||
porchlight.social,43.6532,-79.3832
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
relay.endfiat.money,59.3327,18.0656
|
||||
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||
relay.nearhood.co.uk,51.5134,-0.0890675
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
nostr.novacisko.cz,52.2026,20.9397
|
||||
prl.plus,55.7628,37.5983
|
||||
bruh.samt.st,43.6532,-79.3832
|
||||
strfry.openhoofd.nl,51.5717,3.70417
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
nostr.na.social,43.6532,-79.3832
|
||||
nip85.nosfabrica.com,39.0997,-94.5786
|
||||
premium.primal.net,43.6532,-79.3832
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
relay.scuba323.com,40.8218,-74.45
|
||||
nostr2.girino.org:443,43.6532,-79.3832
|
||||
relay.mmwaves.de,48.8575,2.35138
|
||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
||||
strfry.shock.network:443,39.0438,-77.4874
|
||||
nostr.snowbla.de,50.4754,12.3683
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
nostr.quali.chat,60.1699,24.9384
|
||||
wot.utxo.one,43.6532,-79.3832
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
mostro-p2p.tech,50.1109,8.68213
|
||||
basspistol.org,49.0291,8.35696
|
||||
ribo.nostria.app:443,43.6532,-79.3832
|
||||
chorus.mikedilger.com:444,-36.8906,174.794
|
||||
nostr.oxtr.dev:443,50.4754,12.3683
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
offchain.bostr.online,43.6532,-79.3832
|
||||
purplerelay.com,43.6532,-79.3832
|
||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||
relay.wavefunc.live,41.8781,-87.6298
|
||||
relay.dreamith.to,43.6532,-79.3832
|
||||
bendernostur.duckdns.org:8443,50.1109,8.68213
|
||||
relay.nmail.li,50.9871,2.12554
|
||||
nostr-relay.corb.net,39.6478,-104.988
|
||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||
spamspamspamspam.rest,43.6532,-79.3832
|
||||
relay1.gfcom.info,13.9215,100.538
|
||||
schnorr.me:443,43.6532,-79.3832
|
||||
relay.lab.rytswd.com:443,49.4543,11.0746
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
||||
relay.nostrmap.net,60.1699,24.9384
|
||||
nostr.relay.hedwig.sh,60.1699,24.9384
|
||||
relay.veganostr.com:443,60.1699,24.9384
|
||||
relay.wavefunc.live:443,41.8781,-87.6298
|
||||
nostr.mikoshi.de,52.52,13.405
|
||||
syb.lol,34.0549,-118.243
|
||||
relay1.nostrchat.io,60.1699,24.9384
|
||||
nostr.wecsats.io:443,43.6532,-79.3832
|
||||
nostr.chaima.info:443,51.5072,-0.127586
|
||||
nostr.azzamo.net:443,52.2633,21.0283
|
||||
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
|
||||
nostr.unkn0wn.world,46.8499,9.53287
|
||||
relayone.soundhsa.com,39.0997,-94.5786
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||
nostrelay.circum.space,52.6907,4.8181
|
||||
relay.primal.net,43.6532,-79.3832
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
nostr.pbfs.io,50.4754,12.3683
|
||||
relay.kalcafe.xyz,37.3986,-121.964
|
||||
relay.gulugulu.moe,43.6532,-79.3832
|
||||
top.testrelay.top,43.6532,-79.3832
|
||||
relay.kilombino.com,43.6532,-79.3832
|
||||
nos.lol:443,50.4754,12.3683
|
||||
nos.lol,50.4754,12.3683
|
||||
relay.nostr.place:443,43.6532,-79.3832
|
||||
cache.trustr.ing,43.6548,-79.3885
|
||||
relay.internationalright-wing.org:443,-22.5022,-48.7114
|
||||
relay.laantungir.net,-19.4692,-42.5315
|
||||
relay.lightning.pub:443,39.0438,-77.4874
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
relay.wisp.talk,49.4543,11.0746
|
||||
relay.pyramid.li,47.4093,8.46503
|
||||
relay.typedcypher.com:443,51.5072,-0.127586
|
||||
dev.relay.stream,43.6532,-79.3832
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
nostr.mom:443,50.4754,12.3683
|
||||
relay.plebeian.market:443,50.1109,8.68213
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
nostrcity-club.fly.dev,37.7648,-122.432
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||
weboftrust.libretechsystems.xyz,55.4724,9.87335
|
||||
nostr-relay.corb.net:443,39.6478,-104.988
|
||||
wheat.happytavern.co,43.6532,-79.3832
|
||||
relay.mappingbitcoin.com,43.6532,-79.3832
|
||||
testnet-relay.samt.st,40.8302,-74.1299
|
||||
relay.bitmacro.cloud,43.6532,-79.3832
|
||||
dev.relay.edufeed.org,49.4521,11.0767
|
||||
myvoiceourstory.org,37.3598,-121.981
|
||||
relay.stickeroo.is-cool.dev,37.3387,-121.885
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
freelay.sovbit.host,60.1699,24.9384
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
nostr.middling.mydns.jp,35.8099,140.12
|
||||
cs-relay.nostrdev.com,50.4754,12.3683
|
||||
x.kojira.io:443,43.6532,-79.3832
|
||||
nostrelay.circum.space:443,52.6907,4.8181
|
||||
nostr.janx.com,43.6532,-79.3832
|
||||
relay.mrmave.work,43.6532,-79.3832
|
||||
espelho.girino.org,43.6532,-79.3832
|
||||
hol.is,43.6532,-79.3832
|
||||
ribo.eu.nostria.app,43.6532,-79.3832
|
||||
nostr.yutakobayashi.com,43.6532,-79.3832
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
communities.nos.social,40.8302,-74.1299
|
||||
relay.solife.me,43.6532,-79.3832
|
||||
nostr.linky.fit,50.1109,8.68213
|
||||
public.obelisk.ar,43.6532,-79.3832
|
||||
relay.i9.eti.br,43.6532,-79.3832
|
||||
yabu.me,35.6092,139.73
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
nostr.wecsats.io,43.6532,-79.3832
|
||||
nostr.tac.lol:443,47.4748,-122.273
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
nostrride.io,37.3986,-121.964
|
||||
r.0kb.io:443,32.789,-96.7989
|
||||
herbstmeister.com,34.0549,-118.243
|
||||
relay.artx.market,43.6548,-79.3885
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
relay.ru.ac.th,13.7607,100.627
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
social.amanah.eblessing.co,48.1046,11.6002
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
wot.codingarena.top,50.4754,12.3683
|
||||
relay.sincensura.org,43.6532,-79.3832
|
||||
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
|
||||
relay.nostu.be,40.4167,-3.70329
|
||||
relay.favillakey.io,-27.4705,153.026
|
||||
nostr.mikoshi.de,50.1109,8.68213
|
||||
relay.agilesolutionlabs.com,43.6532,-79.3832
|
||||
nostr.sovereignservices.xyz,43.6532,-79.3832
|
||||
relay.qstr.app,50.1109,8.68213
|
||||
articles.layer3.news:443,37.3387,-121.885
|
||||
relay.nostrops.com,32.71,-96.6745
|
||||
relay.mrmave.work,43.6532,-79.3832
|
||||
nostr.islandarea.net:443,35.4669,-97.6473
|
||||
nostr.azzamo.net:443,52.2633,21.0283
|
||||
ec2.f7z.io,60.1699,24.9384
|
||||
relay.veganostr.com,60.1699,24.9384
|
||||
nostr.dlcdevkit.com,40.0992,-83.1141
|
||||
relay.44billion.net,43.6532,-79.3832
|
||||
bendernostur.duckdns.org:8443,50.1109,8.68213
|
||||
nostras.app,38.7946,-106.535
|
||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
support.flotilla.social,32.9483,-96.7299
|
||||
relay.ordoplay.com,50.1109,8.68213
|
||||
nostr.stakey.net:443,52.3676,4.90414
|
||||
relay.nostrmap.net,60.1699,24.9384
|
||||
relayone.geektank.ai,39.0997,-94.5786
|
||||
relay.novospes.com,43.6532,-79.3832
|
||||
relay.dyne.org,49.0291,8.35705
|
||||
nostrrelay.taylorperron.com,45.5029,-73.5723
|
||||
nostrbtc.com,43.6532,-79.3832
|
||||
relay.lanavault.space,60.1699,24.9384
|
||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
||||
relay.layer.systems:443,49.0291,8.35695
|
||||
vault.iris.to:443,43.6532,-79.3832
|
||||
relay.getsafebox.app:443,43.6532,-79.3832
|
||||
relay.wellorder.net,45.5201,-122.99
|
||||
relay.scuba323.com,40.8218,-74.45
|
||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
relay.paulstephenborile.com,49.4543,11.0746
|
||||
relay.cyberguy.fyi,52.6907,4.8181
|
||||
nostr.wecsats.io:443,43.6532,-79.3832
|
||||
bread.nostrsms.com,40.8218,-74.45
|
||||
relay.plebeian.market,50.1109,8.68213
|
||||
relay.laantungir.net,-19.4692,-42.5315
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
nip85.nosfabrica.com,39.0997,-94.5786
|
||||
relay.conduit.market,38.7946,-106.535
|
||||
relay.sigit.io,50.4754,12.3683
|
||||
relay.dreamith.to,43.6532,-79.3832
|
||||
relay.decentralia.fr,48.122,11.589
|
||||
nexus.libernet.app,43.6532,-79.3832
|
||||
relay.nmail.li,50.9871,2.12554
|
||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||
nostr-relay.corb.net,39.6478,-104.988
|
||||
relay.agentry.com,42.8864,-78.8784
|
||||
budabit.nostr1.com,40.7057,-74.0136
|
||||
relay.cypherflow.ai,48.8575,2.35138
|
||||
purplerelay.com,43.6532,-79.3832
|
||||
nostr.islandarea.net,35.4669,-97.6473
|
||||
relay.edufeed.org,49.4521,11.0767
|
||||
relay.bnos.space,43.6532,-79.3832
|
||||
relay.kizuna-miki.com,35.694,139.754
|
||||
nostr.snowbla.de,50.4754,12.3683
|
||||
bitchat.nostr1.com,40.7057,-74.0136
|
||||
relay.beamhop.com,43.6532,-79.3832
|
||||
relay.wavefunc.live,41.8781,-87.6298
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
relay.nostrhub.fr,48.1045,11.6004
|
||||
relay01.lnfi.network,35.6764,139.65
|
||||
relay.bitos.space,43.6532,-79.3832
|
||||
relay.noeudlibre.fr,50.6924,3.20113
|
||||
relay.orly.dev,32.7767,-96.797
|
||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||
relay.atsocy.com,43.6532,-79.3832
|
||||
relay.keykeeper.world,40.7824,-74.0711
|
||||
nostrmxn.lulus.com.mx,59.4016,17.9455
|
||||
maxq.descendant.io,43.6532,-79.3832
|
||||
nostr.spicyz.io:443,43.6532,-79.3832
|
||||
nostr.chaima.info:443,50.1109,8.68213
|
||||
relay.opmaat.org,60.1699,24.9384
|
||||
nostr.notribe.net,40.8302,-74.1299
|
||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||
offchain.pub,39.1585,-94.5728
|
||||
relay.agora.social,50.7383,15.0648
|
||||
relay.tdw.lol,41.8781,-87.6298
|
||||
nostr.nodesmap.com,59.3327,18.0656
|
||||
relay.getvia.xyz,60.1699,24.9384
|
||||
public.crostr.com,43.6532,-79.3832
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
relay.lacrypta.ar,43.6532,-79.3832
|
||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||
relay.nuts.cash,52.3676,4.90414
|
||||
relay.otrta.me,50.1109,8.68213
|
||||
relay.satmaxt.xyz,43.6532,-79.3832
|
||||
relay.earthly.city,34.1749,-118.54
|
||||
relay.wisp.talk:443,49.4543,11.0746
|
||||
mostro-p2p.tech,50.1109,8.68213
|
||||
nostr.thalheim.io,60.1699,24.9384
|
||||
relay.s-w.art,43.6532,-79.3832
|
||||
nostr.chaima.info,50.1109,8.68213
|
||||
articles.layer3.news,37.3387,-121.885
|
||||
nostr2.girino.org:443,43.6532,-79.3832
|
||||
relay.mostr.pub:443,43.6532,-79.3832
|
||||
relay.ditto.pub:443,43.6532,-79.3832
|
||||
relay.manneken.brussels,49.4543,11.0746
|
||||
relay.kaleidoswap.com,50.8476,4.35717
|
||||
nostrelay.circum.space,52.2245,8.826
|
||||
relay.beginningend.com,35.2227,-97.4786
|
||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
relay.jmoose.rocks:443,37.4419,-122.143
|
||||
relay.nostr.dev.br,48.1046,11.6002
|
||||
relay.minibolt.info,43.6532,-79.3832
|
||||
nostr.unkn0wn.world,46.8499,9.53287
|
||||
vault.iris.to,43.6532,-79.3832
|
||||
relay.guggero.org,46.5971,9.59652
|
||||
relay.getsafebox.app,43.6532,-79.3832
|
||||
relay.bitmacro.cloud,43.6532,-79.3832
|
||||
relay.wavlake.com,41.2619,-95.8608
|
||||
nostr.hekster.org:443,37.3986,-121.964
|
||||
relay.lightning.pub,39.0438,-77.4874
|
||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||
soloco.nl,43.6532,-79.3832
|
||||
nostr.hifish.org,47.4009,8.57814
|
||||
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
|
||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||
relay.wavefunc.live:443,41.8781,-87.6298
|
||||
porchlight.social,43.6532,-79.3832
|
||||
relay.zone667.com,60.1699,24.9384
|
||||
nostr.data.haus:443,50.4754,12.3683
|
||||
relay.nexterz.com,43.6532,-79.3832
|
||||
relay.sigit.io:443,50.4754,12.3683
|
||||
nostr.girino.org,43.6532,-79.3832
|
||||
nostr.liberty.fans,36.8767,-89.5879
|
||||
wheat.happytavern.co,43.6532,-79.3832
|
||||
relay.0xchat.com,43.6532,-79.3832
|
||||
inbox.scuba323.com,40.8218,-74.45
|
||||
nostr.vulpem.com,49.4543,11.0746
|
||||
relay.damustr.com,43.6532,-79.3832
|
||||
no.str.cr:443,8.96171,-83.5246
|
||||
relay.vrtmrz.net:443,43.6532,-79.3832
|
||||
vm-1734.lnvps.cloud,53.3498,-6.26031
|
||||
testnet.samt.st,43.6532,-79.3832
|
||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||
nostr.sathoarder.com:443,48.5734,7.75211
|
||||
relay.cosmicbolt.net,37.3986,-121.964
|
||||
relay.staging.plebeian.market,51.5072,-0.127586
|
||||
relayone.soundhsa.com,39.0997,-94.5786
|
||||
relay.olas.app:443,60.1699,24.9384
|
||||
relay.illuminodes.com,43.6532,-79.3832
|
||||
relay.chorus.community:443,48.5333,10.7
|
||||
relay.ohstr.com:443,43.6532,-79.3832
|
||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||
nostr.planix.org,43.6532,-79.3832
|
||||
schnorr.me,43.6532,-79.3832
|
||||
relay.mostro.network,40.8302,-74.1299
|
||||
nostr-verified.wellorder.net,45.5201,-122.99
|
||||
ynostr.yael.at,60.1699,24.9384
|
||||
nostr.wild-vibes.ts.net,48.8566,2.35222
|
||||
relay.mypathtofire.de,42.8864,-78.8784
|
||||
relay.klabo.world,47.674,-122.122
|
||||
nostr.tagomago.me,42.3601,-71.0589
|
||||
relay.veganostr.com:443,60.1699,24.9384
|
||||
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
|
||||
relay.loveisbitcoin.com,43.6532,-79.3832
|
||||
bridge.tagomago.me,42.3601,-71.0589
|
||||
relay-rpi.edufeed.org:443,49.4521,11.0767
|
||||
relay.kilombino.com,43.6532,-79.3832
|
||||
relay.sharegap.net,43.6532,-79.3832
|
||||
nostr.yutakobayashi.com,43.6532,-79.3832
|
||||
relay-na1.metanomalist.com,43.6532,-79.3832
|
||||
public.crostr.com:443,43.6532,-79.3832
|
||||
nostrride.io,37.3986,-121.964
|
||||
nostr.bgbitcoin.club:443,50.4754,12.3683
|
||||
nostr.christiansass.de,50.1109,8.68213
|
||||
nos.lol:443,50.4754,12.3683
|
||||
relay.pyramid.li,47.4093,8.46503
|
||||
relay.mappingbitcoin.com,43.6532,-79.3832
|
||||
nostr-relay.zimage.com,34.282,-118.439
|
||||
offchain.pub:443,39.1585,-94.5728
|
||||
nostr.myshosholoza.co.za:443,52.3676,4.90414
|
||||
0x-nostr-relay.fly.dev,38.7946,-106.535
|
||||
relay1.privkey.io,25.7617,-80.1918
|
||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||
nostr.robosats.org,64.1476,-21.9392
|
||||
relay.endfiat.money:443,59.3327,18.0656
|
||||
nostr.agh.ccwu.cc,43.6532,-79.3832
|
||||
nostr.overmind.lol:443,43.6532,-79.3832
|
||||
nostr.tac.lol,47.4748,-122.273
|
||||
relay.ru.ac.th,13.7607,100.627
|
||||
nostr-relay.xbytez.io,50.6924,3.20113
|
||||
relay.islandbitcoin.com,12.8498,77.6545
|
||||
relay5.bitransfer.org,43.6532,-79.3832
|
||||
prl.plus,55.7628,37.5983
|
||||
chat-relay.zap-work.com,43.6532,-79.3832
|
||||
relay.satlantis.io,39.0438,-77.4874
|
||||
nostr.bitcoiner.social:443,40.7608,-111.891
|
||||
nostr.ac,38.958,-77.3592
|
||||
relay.solomonstr.com,43.6532,-79.3832
|
||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||
relay.nostr.com,50.1109,8.68213
|
||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||
nostr.bond,50.1109,8.68213
|
||||
relay.wavlake.com:443,41.2619,-95.8608
|
||||
nos.lol,50.4754,12.3683
|
||||
nostr.debate.report,50.1109,8.68213
|
||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||
relay.wisp.talk,49.4543,11.0746
|
||||
nostr.tabordalab.com,60.1699,24.9384
|
||||
relay.trotters.cc,43.6532,-79.3832
|
||||
nostr.4rs.nl,49.0291,8.35696
|
||||
relay.hivetalk.org,40.8302,-74.1299
|
||||
spamspamspamspam.rest,43.6532,-79.3832
|
||||
nostr.janx.com,43.6532,-79.3832
|
||||
relay.mostr.pub,43.6532,-79.3832
|
||||
relay.lightning.pub:443,39.0438,-77.4874
|
||||
relay.samt.st,40.8302,-74.1299
|
||||
bruh.samt.st,43.6532,-79.3832
|
||||
relay.orangesync.tech,40.7128,-74.006
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
relay.notoshi.win,13.3396,100.93
|
||||
relay.nostreon.com,60.1699,24.9384
|
||||
nostr.purpura.cloud,43.6532,-79.3832
|
||||
basspistol.org,49.0291,8.35696
|
||||
relay.nostrfeed.com,60.1699,24.9384
|
||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||
nostr.21crypto.ch,47.5356,8.73209
|
||||
nostr.pbfs.io,50.4754,12.3683
|
||||
social.amanah.eblessing.co,48.1046,11.6002
|
||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
||||
21milionidinostr.duckdns.org,41.8967,12.4822
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
nostr.na.social,43.6532,-79.3832
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
strfry.shock.network:443,39.0438,-77.4874
|
||||
nostr.pbfs.io:443,50.4754,12.3683
|
||||
relay.shadowbip.com,50.1109,8.68213
|
||||
relay.ltgnet.work:8443,51.0511,-114.075
|
||||
relay02.lnfi.network,35.6764,139.65
|
||||
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||
relay.satsmarkt.club,52.6907,4.8181
|
||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
||||
auth.nostr1.com,40.7057,-74.0136
|
||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
||||
bitcoinostr.duckdns.org,38.9504,-0.14007
|
||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||
nostr.whitenode45.ddns.net,40.55,-74.4758
|
||||
relay.mccormick.cx:443,52.3563,4.95714
|
||||
nostr.2b9t.xyz:443,34.0549,-118.243
|
||||
relay.internationalright-wing.org,-22.4692,-48.9875
|
||||
nostr.2b9t.xyz,34.0549,-118.243
|
||||
relay.mitchelltribe.com,39.0438,-77.4874
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
|
||||
relay.sector01.com,41.4513,-81.7021
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
relay.fiatdenier.com,43.7787,-79.5393
|
||||
relayone.soundhsa.com:443,39.0997,-94.5786
|
||||
strfry.bonsai.com:443,39.0438,-77.4874
|
||||
nostr.snowbla.de:443,50.4754,12.3683
|
||||
relay.btcforplebs.com,43.6532,-79.3832
|
||||
dev.relay.stream,43.6532,-79.3832
|
||||
nostr-pub.wellorder.net,45.5201,-122.99
|
||||
nostr.twinkle.lol,51.902,7.6657
|
||||
nostr.carroarmato0.be,50.914,3.21378
|
||||
bcast.girino.org,43.6532,-79.3832
|
||||
treuzkas.branruz.com,48.8575,2.35138
|
||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||
relay.endfiat.money,59.3327,18.0656
|
||||
relay.jmoose.rocks,37.4419,-122.143
|
||||
relay.degmods.com,50.4754,12.3683
|
||||
relay.ditto.pub,43.6532,-79.3832
|
||||
nostrelay.circum.space:443,52.2245,8.826
|
||||
relay.pocketnostr.com,40.8054,-74.0241
|
||||
nostr.wecsats.io,43.6532,-79.3832
|
||||
node.kommonzenze.de,49.4521,11.0767
|
||||
nostr.sathoarder.com,48.5734,7.75211
|
||||
chorus.mikedilger.com:444,-36.8906,174.794
|
||||
cache.trustr.ing,43.6548,-79.3885
|
||||
bitcoiner.social,40.7608,-111.891
|
||||
relay.tdw.gg,34.0549,-118.243
|
||||
relay.edufeed.org:443,49.4521,11.0767
|
||||
relay.snotr.nl:49999,51.9758,4.31389
|
||||
nostr.bgbitcoin.club,50.4754,12.3683
|
||||
relay.bowlafterbowl.com,32.9483,-96.7299
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
x.kojira.io:443,43.6532,-79.3832
|
||||
nostr.self-determined.de,51.4379,14.2355
|
||||
schnorr.me:443,43.6532,-79.3832
|
||||
nostr.computingcache.com,45.5341,-122.956
|
||||
cs-relay.nostrdev.com,50.4754,12.3683
|
||||
relay2.veganostr.com,60.1699,24.9384
|
||||
relay.phostrich.com,40.55,-74.4758
|
||||
relay.nostrian-conquest.com,41.223,-111.974
|
||||
relay.flashapp.me,43.6548,-79.3885
|
||||
relay.yoinekodo.jp,43.6532,-79.3832
|
||||
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||
freelay.sovbit.host,60.1699,24.9384
|
||||
nostr.n7ekb.net,47.4941,-122.294
|
||||
purplerelay.com:443,43.6532,-79.3832
|
||||
nostr.tbxnetworx.de,47.7228,9.96456
|
||||
relay.bnos.space:443,43.6532,-79.3832
|
||||
relay.satsapp.me,50.4754,12.3683
|
||||
nostr-01.uid.ovh,50.9871,2.12554
|
||||
tribune-panel-growing-noon.trycloudflare.com,43.6532,-79.3832
|
||||
nostr.mom,50.4754,12.3683
|
||||
no.str.cr,8.96171,-83.5246
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
memlay.v0l.io,53.3498,-6.26031
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
nostr.azzamo.net,52.2633,21.0283
|
||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
conduitl2.fly.dev,38.7946,-106.535
|
||||
relay.wasabiwallet.io,43.6532,-79.3832
|
||||
portal-relay.pareto.space,49.0291,8.35696
|
||||
strfry.ymir.cloud,43.6532,-79.3832
|
||||
nostr.hoppe-relay.it.com,42.8864,-78.8784
|
||||
nostr.red5d.dev,43.6532,-79.3832
|
||||
strfry.shock.network,39.0438,-77.4874
|
||||
relay.chorus.community,48.5333,10.7
|
||||
relay.mitchelltribe.com:443,39.0438,-77.4874
|
||||
relay.olas.app,60.1699,24.9384
|
||||
testnet-relay.samt.st:443,40.8302,-74.1299
|
||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||
relay.gulugulu.moe,43.6532,-79.3832
|
||||
relay.underorion.se,50.1109,8.68213
|
||||
relay.mccormick.cx,52.3563,4.95714
|
||||
top.testrelay.top,43.6532,-79.3832
|
||||
nostr-02.uid.ovh,50.9871,2.12554
|
||||
nostr.relay.hedwig.sh,60.1699,24.9384
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
relay.nostr.net,43.6532,-79.3832
|
||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||
nrs-01.darkcloudarcade.com,39.0997,-94.5786
|
||||
relay.trotters.cc:443,43.6532,-79.3832
|
||||
strfry.bonsai.com,39.0438,-77.4874
|
||||
relay.snort.social,53.3498,-6.26031
|
||||
nostr.hekster.org,37.3986,-121.964
|
||||
relay.mleku.dev,32.7767,-96.797
|
||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
||||
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
|
||||
nostr.davenov.com,50.1109,8.68213
|
||||
relay.chatbett.de,40.7128,-74.006
|
||||
relayone.geektank.ai:443,39.0997,-94.5786
|
||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
||||
nostr.mom:443,50.4754,12.3683
|
||||
nostr-relay.corb.net:443,39.6478,-104.988
|
||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
relay.nearhood.co.uk,51.5134,-0.0890675
|
||||
relay1.nostrchat.io,60.1699,24.9384
|
||||
rele.speyhard.fi,50.1109,8.68213
|
||||
relay.bornheimer.app,50.1109,8.68213
|
||||
nostr.fullstackcash.net,45.5201,-122.99
|
||||
relay2.fiatdenier.com,50.1013,8.62643
|
||||
relay.solife.me,43.6532,-79.3832
|
||||
syb.lol,34.0549,-118.243
|
||||
dev-relay.nostreon.com,60.1699,24.9384
|
||||
relay.agorist.space:443,52.3734,4.89406
|
||||
nostr.easycryptosend.it,43.6532,-79.3832
|
||||
x.kojira.io,43.6532,-79.3832
|
||||
adre.su,59.9311,30.3609
|
||||
offchain.bostr.online,43.6532,-79.3832
|
||||
relay.libernet.app,43.6532,-79.3832
|
||||
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
relay.layer.systems,49.0291,8.35695
|
||||
relay.arx-ccn.com,50.4754,12.3683
|
||||
staging.yabu.me,35.6092,139.73
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
nostr.bitcoiner.social,40.7608,-111.891
|
||||
nostr.88mph.life,52.1941,-2.21905
|
||||
relay.pprgb.app,52.52,13.405
|
||||
relay.aarpia.com,37.3986,-121.964
|
||||
nostrcity-club.fly.dev,38.7946,-106.535
|
||||
buzz.ac2n-share.kozow.com,45.764,4.83566
|
||||
relay.fckstate.net,59.3293,18.0686
|
||||
nostr.thalheim.io:443,60.1699,24.9384
|
||||
nostr.carroarmato0.be:443,50.914,3.21378
|
||||
nostrcity-club.fly.dev:443,38.7946,-106.535
|
||||
relay.routstr.com,59.4016,17.9455
|
||||
nostr-dev.wellorder.net,45.5201,-122.99
|
||||
relay.directsponsor.net,42.8864,-78.8784
|
||||
relay.mwaters.net,50.9871,2.12554
|
||||
relay.littlebitstudios.com,43.6532,-79.3832
|
||||
relay.ohstr.com,43.6532,-79.3832
|
||||
testnet-relay.samt.st,40.8302,-74.1299
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
relay.nostrmap.net:443,60.1699,24.9384
|
||||
relay.piazza.today,48.122,11.589
|
||||
|
||||
|
1
app/src/main/assets/world_borders.geojson
Normal file
1
app/src/main/assets/world_borders.geojson
Normal file
File diff suppressed because one or more lines are too long
1
app/src/main/assets/world_cities.geojson
Normal file
1
app/src/main/assets/world_cities.geojson
Normal file
File diff suppressed because one or more lines are too long
1
app/src/main/assets/world_land.geojson
Normal file
1
app/src/main/assets/world_land.geojson
Normal file
File diff suppressed because one or more lines are too long
@ -33,6 +33,13 @@ class BitchatApplication : Application() {
|
||||
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)
|
||||
@ -41,6 +48,9 @@ class BitchatApplication : Application() {
|
||||
// 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) { }
|
||||
|
||||
|
||||
@ -428,6 +428,8 @@ class MainActivity : OrientationAwareActivity() {
|
||||
|
||||
if (permissionManager.isFirstTimeLaunch()) {
|
||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||
} else if (permissionManager.getUnrequestedOptionalPermissions().isNotEmpty()) {
|
||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||
} else if (permissionManager.areRequiredPermissionsGranted()) {
|
||||
if (permissionManager.needsBackgroundLocationPermission() &&
|
||||
!permissionManager.isBackgroundLocationGranted() &&
|
||||
|
||||
@ -52,6 +52,7 @@ fun AnnotatedClickableText(
|
||||
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)
|
||||
@ -91,6 +92,9 @@ fun AnnotatedClickableText(
|
||||
softWrap = softWrap,
|
||||
overflow = overflow,
|
||||
style = style,
|
||||
onTextLayout = { layoutResult = it },
|
||||
onTextLayout = { result ->
|
||||
layoutResult = result
|
||||
onTextLayout?.invoke(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@ -48,6 +48,25 @@ data class FavoriteRelationship(
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@ -167,6 +186,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
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")
|
||||
@ -233,18 +253,13 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
|
||||
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
|
||||
val existing = favorites[keyHex]
|
||||
val updated = existing.withPeerFavoritedUs(noisePublicKey, theyFavoritedUs)
|
||||
|
||||
if (existing != null) {
|
||||
val updated = existing.copy(
|
||||
theyFavoritedUs = theyFavoritedUs,
|
||||
lastUpdated = Date()
|
||||
)
|
||||
favorites[keyHex] = updated
|
||||
saveFavorites()
|
||||
notifyChanged(keyHex)
|
||||
favorites[keyHex] = updated
|
||||
saveFavorites()
|
||||
notifyChanged(keyHex)
|
||||
|
||||
Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs")
|
||||
}
|
||||
Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs")
|
||||
}
|
||||
|
||||
fun getMutualFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isMutual }
|
||||
|
||||
@ -5,6 +5,8 @@ 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
|
||||
@ -323,4 +325,80 @@ object FileUtils {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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() }
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,17 +18,38 @@ 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) {
|
||||
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"
|
||||
@ -56,13 +77,19 @@ class VoiceRecorder(private val context: Context) {
|
||||
|
||||
fun pollAmplitude(): Int {
|
||||
return try {
|
||||
val amp = recorder?.maxAmplitude ?: 0
|
||||
val amp = liveCapture?.pollAmplitude() ?: recorder?.maxAmplitude ?: 0
|
||||
_amplitude.value = amp
|
||||
amp
|
||||
} catch (_: Exception) { 0 }
|
||||
}
|
||||
|
||||
fun stop(): File? {
|
||||
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) {}
|
||||
@ -73,6 +100,10 @@ class VoiceRecorder(private val context: Context) {
|
||||
val f = outFile
|
||||
recorder = null
|
||||
outFile = null
|
||||
if (canceled) {
|
||||
f?.delete()
|
||||
return null
|
||||
}
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,12 +28,14 @@ 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
|
||||
@ -137,6 +139,12 @@ fun HotspotScreen(
|
||||
is HotspotViewModel.HotspotState.Starting -> {
|
||||
LoadingScreen()
|
||||
}
|
||||
is HotspotViewModel.HotspotState.ConfirmDisconnect -> {
|
||||
ExistingGroupConfirmation(
|
||||
onConfirm = viewModel::confirmDisconnectAndStart,
|
||||
onCancel = viewModel::cancelDisconnect
|
||||
)
|
||||
}
|
||||
is HotspotViewModel.HotspotState.Active -> {
|
||||
ActiveHotspotScreen(state = currentState)
|
||||
}
|
||||
@ -152,6 +160,28 @@ fun HotspotScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@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) {
|
||||
|
||||
@ -28,10 +28,6 @@ class HotspotManager(private val context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "HotspotMgr"
|
||||
|
||||
// Retry configuration
|
||||
private const val MAX_FRAMEWORK_ATTEMPTS = 5
|
||||
private const val RETRY_DELAY_MILLIS = 1000L
|
||||
|
||||
// Group info polling interval
|
||||
private const val GROUP_INFO_POLL_INTERVAL_MILLIS = 1000L
|
||||
|
||||
@ -39,10 +35,14 @@ class HotspotManager(private val context: Context) {
|
||||
private const val GROUP_FORMATION_TIMEOUT_MILLIS = 15_000L
|
||||
|
||||
// SSID and password configuration
|
||||
private const val SSID_PREFIX = "DIRECT-BC-" // BC for BitChat
|
||||
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
|
||||
}
|
||||
@ -63,17 +63,56 @@ class HotspotManager(private val context: Context) {
|
||||
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")
|
||||
@ -85,8 +124,13 @@ class HotspotManager(private val context: Context) {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
fun startHotspot(callback: HotspotCallback, confirmedReplacementName: String? = null) {
|
||||
if (isStarting) {
|
||||
Log.w(TAG, "Hotspot already starting")
|
||||
return
|
||||
@ -111,6 +155,7 @@ class HotspotManager(private val context: Context) {
|
||||
}
|
||||
|
||||
this.callback = callback
|
||||
this.confirmedReplacementName = confirmedReplacementName
|
||||
isStarting = true
|
||||
|
||||
Log.d(TAG, "Starting Wi-Fi P2P hotspot")
|
||||
@ -138,32 +183,59 @@ class HotspotManager(private val context: Context) {
|
||||
Log.d(TAG, "Using saved credentials: SSID=$savedSsid")
|
||||
}
|
||||
|
||||
// Start P2P framework with retries
|
||||
startWifiP2pFramework(1)
|
||||
// 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() {
|
||||
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)
|
||||
|
||||
// Remove group
|
||||
channel?.let { ch ->
|
||||
wifiP2pManager?.removeGroup(ch, object : ActionListener {
|
||||
override fun onSuccess() {
|
||||
Log.d(TAG, "Group removed successfully")
|
||||
}
|
||||
override fun onFailure(reason: Int) {
|
||||
Log.w(TAG, "Failed to remove group: $reason")
|
||||
}
|
||||
})
|
||||
// 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
|
||||
@ -182,8 +254,101 @@ class HotspotManager(private val context: Context) {
|
||||
}
|
||||
|
||||
currentGroup = null
|
||||
channel = 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -202,39 +367,144 @@ class HotspotManager(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Wi-Fi P2P framework with retry logic.
|
||||
* 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(attempt: Int) {
|
||||
if (attempt > MAX_FRAMEWORK_ATTEMPTS) {
|
||||
Log.e(TAG, "Failed to start P2P framework after $MAX_FRAMEWORK_ATTEMPTS attempts")
|
||||
failStartup("Failed to start hotspot. Please try again.")
|
||||
return
|
||||
}
|
||||
private fun startWifiP2pFramework() {
|
||||
Log.d(TAG, "Initialising P2P channel")
|
||||
|
||||
Log.d(TAG, "Starting P2P framework (attempt $attempt/$MAX_FRAMEWORK_ATTEMPTS)")
|
||||
val newChannel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null)
|
||||
|
||||
channel = wifiP2pManager?.initialize(context, Looper.getMainLooper(), null)
|
||||
|
||||
if (channel == null) {
|
||||
if (newChannel == null) {
|
||||
// The service is unobtainable; retrying will not change that.
|
||||
Log.e(TAG, "Failed to initialize P2P channel")
|
||||
handler.postDelayed({
|
||||
startWifiP2pFramework(attempt + 1)
|
||||
}, RETRY_DELAY_MILLIS)
|
||||
failStartup(HotspotStartupPolicy.P2P_UNSUPPORTED_MESSAGE)
|
||||
return
|
||||
}
|
||||
|
||||
createGroup(attempt)
|
||||
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) {
|
||||
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!!)
|
||||
@ -256,11 +526,18 @@ class HotspotManager(private val context: Context) {
|
||||
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")
|
||||
wifiP2pManager?.removeGroup(requestChannel, null)
|
||||
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()
|
||||
@ -274,7 +551,7 @@ class HotspotManager(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle group creation failure with retry logic.
|
||||
* Handle group creation failure, backing off only for genuinely transient causes.
|
||||
*/
|
||||
private fun handleGroupCreationFailure(reason: Int, attempt: Int) {
|
||||
val reasonStr = when (reason) {
|
||||
@ -284,16 +561,22 @@ class HotspotManager(private val context: Context) {
|
||||
else -> "UNKNOWN($reason)"
|
||||
}
|
||||
|
||||
Log.w(TAG, "Failed to create group: $reasonStr")
|
||||
Log.w(
|
||||
TAG,
|
||||
"Failed to create group: $reasonStr " +
|
||||
"(attempt $attempt/${HotspotStartupPolicy.MAX_ATTEMPTS}, p2pState=$lastP2pState)"
|
||||
)
|
||||
|
||||
if (reason == BUSY && attempt < MAX_FRAMEWORK_ATTEMPTS) {
|
||||
// Framework is busy, retry
|
||||
Log.d(TAG, "P2P framework busy, retrying...")
|
||||
handler.postDelayed({
|
||||
startWifiP2pFramework(attempt + 1)
|
||||
}, RETRY_DELAY_MILLIS)
|
||||
} else {
|
||||
failStartup("Failed to create hotspot: $reasonStr")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -308,6 +591,17 @@ class HotspotManager(private val context: Context) {
|
||||
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.
|
||||
*/
|
||||
@ -346,26 +640,52 @@ class HotspotManager(private val context: Context) {
|
||||
|
||||
try {
|
||||
wifiP2pManager?.requestGroupInfo(ch) { group ->
|
||||
if (group != null) {
|
||||
currentGroup = group
|
||||
// A reply arriving after the hotspot stopped must not revive any
|
||||
// state the stop just cleared.
|
||||
if (channel !== ch) return@requestGroupInfo
|
||||
|
||||
// Update saved credentials if using system-generated ones
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
savedSsid = group.networkName
|
||||
savedPassword = group.passphrase
|
||||
}
|
||||
reconcileGroupOwnership(group)
|
||||
|
||||
// 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())
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
@ -374,6 +694,63 @@ class HotspotManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@ -460,7 +837,7 @@ class HotspotManager(private val context: Context) {
|
||||
val suffix = (1..SSID_SUFFIX_LENGTH)
|
||||
.map { RANDOM_CHARS[random.nextInt(RANDOM_CHARS.length)] }
|
||||
.joinToString("")
|
||||
return "$SSID_PREFIX$suffix"
|
||||
return "${HotspotStartupPolicy.SSID_PREFIX}$suffix"
|
||||
}
|
||||
|
||||
/**
|
||||
@ -489,6 +866,14 @@ class HotspotManager(private val context: Context) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
package com.bitchat.android.hotspot
|
||||
|
||||
import android.net.wifi.p2p.WifiP2pManager
|
||||
|
||||
/**
|
||||
* Decides how to react to a Wi-Fi P2P group-creation failure.
|
||||
*
|
||||
* Kept free of Android dependencies so the retry strategy is unit testable.
|
||||
*/
|
||||
internal object HotspotStartupPolicy {
|
||||
|
||||
const val MAX_ATTEMPTS = 5
|
||||
const val INITIAL_RETRY_DELAY_MILLIS = 1_000L
|
||||
const val MAX_RETRY_DELAY_MILLIS = 8_000L
|
||||
|
||||
const val P2P_DISABLED_MESSAGE =
|
||||
"Wi-Fi Direct is unavailable. Turn Wi-Fi off and back on, then try again."
|
||||
/** Marks groups this app creates. Shared with [HotspotManager] so the two cannot drift. */
|
||||
const val SSID_PREFIX = "DIRECT-BC-" // BC for BitChat
|
||||
|
||||
const val P2P_UNSUPPORTED_MESSAGE = "Wi-Fi Direct is not supported on this device."
|
||||
const val P2P_BUSY_MESSAGE = "Wi-Fi Direct is busy. Please try again in a moment."
|
||||
const val GENERIC_FAILURE_MESSAGE = "Failed to start the hotspot. Please try again."
|
||||
|
||||
sealed interface Decision {
|
||||
data class Retry(val delayMillis: Long) : Decision
|
||||
data class Fail(val message: String) : Decision
|
||||
}
|
||||
|
||||
sealed interface StartAction {
|
||||
data object Create : StartAction
|
||||
data object RemoveStaleGroupThenCreate : StartAction
|
||||
|
||||
/** A group we cannot show is ours is up; ask the user before disturbing it. */
|
||||
data object ConfirmReplaceExisting : StartAction
|
||||
|
||||
data class Fail(val message: String) : StartAction
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides what to do before the first group-creation attempt.
|
||||
*
|
||||
* A P2P group outlives the process that created it, so an app killed while
|
||||
* hosting leaves an orphan behind. The framework rejects createGroup with BUSY
|
||||
* while any group exists, and no amount of retrying clears it.
|
||||
*
|
||||
* Wi-Fi Direct is shared with Cast, Android Auto and Quick Share. A group we can
|
||||
* show is ours is removed silently; anything else needs the user's explicit
|
||||
* go-ahead before it is touched. Consent is bound to the group it was given
|
||||
* for: a group with any other name — one that appeared after the dialog, or
|
||||
* mid-retry — asks again instead of riding on stale approval.
|
||||
*
|
||||
* @param existingGroupName network name of the group already present, or null
|
||||
* @param ownedGroupName last group name this app recorded creating, or null
|
||||
* @param confirmedGroupName group the user agreed to disconnect, or null
|
||||
*/
|
||||
fun startAction(
|
||||
p2pState: Int?,
|
||||
existingGroupName: String?,
|
||||
ownedGroupName: String?,
|
||||
confirmedGroupName: String?
|
||||
): StartAction = when {
|
||||
p2pState == WifiP2pManager.WIFI_P2P_STATE_DISABLED -> StartAction.Fail(P2P_DISABLED_MESSAGE)
|
||||
existingGroupName == null -> StartAction.Create
|
||||
isOurs(existingGroupName, ownedGroupName) -> StartAction.RemoveStaleGroupThenCreate
|
||||
existingGroupName == confirmedGroupName -> StartAction.RemoveStaleGroupThenCreate
|
||||
else -> StartAction.ConfirmReplaceExisting
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the exact name this device recorded creating counts as ours. A prefix
|
||||
* match is not ownership: this device can be connected to another phone's
|
||||
* bitchat group — same prefix, their suffix — and silently removing it would
|
||||
* disconnect that session. An orphan predating the record simply goes through
|
||||
* the confirmation dialog once.
|
||||
*/
|
||||
private fun isOurs(existingGroupName: String, ownedGroupName: String?): Boolean =
|
||||
ownedGroupName != null && existingGroupName == ownedGroupName
|
||||
|
||||
/** Only an exact owner-role name match authorizes device-scoped removal. */
|
||||
fun isExpectedHostedGroup(
|
||||
existingGroupName: String?,
|
||||
isGroupOwner: Boolean,
|
||||
expectedGroupName: String?
|
||||
): Boolean =
|
||||
isGroupOwner &&
|
||||
expectedGroupName != null &&
|
||||
existingGroupName == expectedGroupName
|
||||
|
||||
/** A stale teardown must not erase the ownership marker of a newer session. */
|
||||
fun shouldClearOwnedGroupName(
|
||||
storedGroupName: String?,
|
||||
removedGroupName: String?
|
||||
): Boolean =
|
||||
removedGroupName != null && storedGroupName == removedGroupName
|
||||
|
||||
/**
|
||||
* @param reason a [WifiP2pManager] failure reason from `ActionListener.onFailure`
|
||||
* @param attempt 1-based attempt that just failed
|
||||
* @param p2pState last known [WifiP2pManager.EXTRA_WIFI_STATE], or null if no
|
||||
* state broadcast has arrived yet
|
||||
*/
|
||||
fun decide(reason: Int, attempt: Int, p2pState: Int?): Decision = when {
|
||||
reason == WifiP2pManager.P2P_UNSUPPORTED -> Decision.Fail(P2P_UNSUPPORTED_MESSAGE)
|
||||
|
||||
reason != WifiP2pManager.BUSY -> Decision.Fail(GENERIC_FAILURE_MESSAGE)
|
||||
|
||||
// BUSY is the framework's catch-all reply when the P2P state machine is
|
||||
// disabled, so retrying cannot help — surface something actionable instead.
|
||||
p2pState == WifiP2pManager.WIFI_P2P_STATE_DISABLED -> Decision.Fail(P2P_DISABLED_MESSAGE)
|
||||
|
||||
attempt >= MAX_ATTEMPTS -> Decision.Fail(P2P_BUSY_MESSAGE)
|
||||
|
||||
else -> Decision.Retry(retryDelayMillis(attempt))
|
||||
}
|
||||
|
||||
private fun retryDelayMillis(attempt: Int): Long =
|
||||
(INITIAL_RETRY_DELAY_MILLIS shl (attempt - 1)).coerceAtMost(MAX_RETRY_DELAY_MILLIS)
|
||||
}
|
||||
@ -1,9 +1,12 @@
|
||||
package com.bitchat.android.hotspot
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitchat.android.wifiaware.WifiAwareController
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@ -17,6 +20,10 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HotspotViewModel"
|
||||
|
||||
// Upper bound on waiting for the framework to acknowledge group removal
|
||||
// before the Wi-Fi Aware lease is released anyway.
|
||||
private const val TEARDOWN_FALLBACK_MILLIS = 10_000L
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<HotspotState>(HotspotState.Intro)
|
||||
@ -24,12 +31,25 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
private var hotspotManager: HotspotManager? = null
|
||||
private var webServer: ApkWebServer? = null
|
||||
|
||||
/** APK waiting on the user's answer to the disconnect confirmation. */
|
||||
private var pendingApk: File? = null
|
||||
|
||||
/** Group the pending confirmation is about; consent binds to this name only. */
|
||||
private var pendingGroupName: String? = null
|
||||
|
||||
/** Once-releasable radio claim owned by the current hotspot session. */
|
||||
private var awareLease: WifiAwareController.HotspotLease? = null
|
||||
private val context = application.applicationContext
|
||||
|
||||
/**
|
||||
* Start the hotspot with the provided APK file.
|
||||
*/
|
||||
fun startHotspot(apkFile: File) {
|
||||
startHotspot(apkFile, confirmedGroupName = null)
|
||||
}
|
||||
|
||||
private fun startHotspot(apkFile: File, confirmedGroupName: String?) {
|
||||
if (_state.value is HotspotState.Starting || _state.value is HotspotState.Active) {
|
||||
Log.w(TAG, "Hotspot already starting or active")
|
||||
return
|
||||
@ -40,6 +60,10 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// Wi-Fi Aware holds a NAN interface that blocks the P2P one; release it
|
||||
// first or every createGroup comes back BUSY. Restored when we stop.
|
||||
awareLease = WifiAwareController.acquireHotspotLease()
|
||||
|
||||
// Start hotspot
|
||||
val manager = HotspotManager(context)
|
||||
hotspotManager = manager
|
||||
@ -52,8 +76,7 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
|
||||
// Get connection info
|
||||
val info = manager.getConnectionInfo()
|
||||
if (info == null) {
|
||||
manager.stopHotspot()
|
||||
_state.value = HotspotState.Error("Failed to get hotspot connection info")
|
||||
failWith("Failed to get hotspot connection info")
|
||||
return@launch
|
||||
}
|
||||
|
||||
@ -75,8 +98,7 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start web server", e)
|
||||
manager.stopHotspot()
|
||||
_state.value = HotspotState.Error("Failed to start web server: ${e.message}")
|
||||
failWith("Failed to start web server: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -91,35 +113,110 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(message: String) {
|
||||
override fun onExistingGroupConflict(groupName: String) {
|
||||
viewModelScope.launch {
|
||||
Log.e(TAG, "Hotspot error: $message")
|
||||
_state.value = HotspotState.Error(message)
|
||||
// Nothing was disturbed; the manager already stopped
|
||||
// itself. Release our resources and ask the user.
|
||||
teardown()
|
||||
pendingApk = apkFile
|
||||
pendingGroupName = groupName
|
||||
_state.value = HotspotState.ConfirmDisconnect
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
override fun onError(message: String) {
|
||||
viewModelScope.launch { failWith(message) }
|
||||
}
|
||||
}, confirmedGroupName)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error starting hotspot", e)
|
||||
hotspotManager?.stopHotspot()
|
||||
_state.value = HotspotState.Error(e.message ?: "Unknown error")
|
||||
failWith(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The user agreed that starting may disconnect the existing Wi-Fi Direct group. */
|
||||
fun confirmDisconnectAndStart() {
|
||||
if (_state.value !is HotspotState.ConfirmDisconnect) return
|
||||
val apk = pendingApk ?: return
|
||||
val groupName = pendingGroupName
|
||||
pendingApk = null
|
||||
pendingGroupName = null
|
||||
startHotspot(apk, confirmedGroupName = groupName)
|
||||
}
|
||||
|
||||
fun cancelDisconnect() {
|
||||
// A tap landing late (e.g. through an exit animation) must not tear down
|
||||
// the session a just-processed confirmation is starting.
|
||||
if (_state.value !is HotspotState.ConfirmDisconnect) return
|
||||
pendingApk = null
|
||||
pendingGroupName = null
|
||||
// The conflict path already tore down; this only covers a stray state.
|
||||
teardown()
|
||||
_state.value = HotspotState.Intro
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the hotspot and web server.
|
||||
*/
|
||||
fun stopHotspot() {
|
||||
Log.d(TAG, "Stopping hotspot")
|
||||
pendingApk = null
|
||||
pendingGroupName = null
|
||||
teardown()
|
||||
_state.value = HotspotState.Intro
|
||||
}
|
||||
|
||||
/**
|
||||
* Every failure after the hotspot has been requested must land here.
|
||||
*
|
||||
* Skipping any part of this leaves something running that shouldn't be: the web
|
||||
* server keeps serving the APK on whatever network the device joins next, and the
|
||||
* Wi-Fi Aware hold blocks the mesh until the user happens to retry or close the
|
||||
* screen.
|
||||
*/
|
||||
private fun failWith(message: String) {
|
||||
Log.e(TAG, "Hotspot failed: $message")
|
||||
pendingApk = null
|
||||
pendingGroupName = null
|
||||
teardown()
|
||||
_state.value = HotspotState.Error(message)
|
||||
}
|
||||
|
||||
/** Releases every resource startHotspot may have acquired. Safe to call twice. */
|
||||
private fun teardown() {
|
||||
webServer?.stopServer()
|
||||
webServer = null
|
||||
|
||||
hotspotManager?.stopHotspot()
|
||||
val manager = hotspotManager
|
||||
hotspotManager = null
|
||||
|
||||
_state.value = HotspotState.Intro
|
||||
// Nothing of ours to hand back. An earlier teardown may already have passed
|
||||
// its once-releasable lease to the manager's completion callback.
|
||||
val lease = awareLease ?: return
|
||||
awareLease = null
|
||||
|
||||
if (manager == null) {
|
||||
lease.close()
|
||||
return
|
||||
}
|
||||
|
||||
// Close the lease only when the manager has finished removing our group.
|
||||
// Restoring Wi-Fi Aware earlier recreates the NAN/P2P radio contention the
|
||||
// lease exists to prevent. The lease is idempotent, so a duplicate or late
|
||||
// completion cannot release a newer session's claim.
|
||||
manager.stopHotspot { lease.close() }
|
||||
|
||||
// A framework that never acknowledges the removal must not pin Wi-Fi Aware
|
||||
// down for the life of the process. close() is idempotent and this lease
|
||||
// belongs to this session alone, so whichever path runs second is a no-op.
|
||||
// A plain handler rather than viewModelScope: onCleared() cancels the scope
|
||||
// right when this teardown may be running.
|
||||
Handler(Looper.getMainLooper()).postDelayed(
|
||||
{ lease.close() },
|
||||
TEARDOWN_FALLBACK_MILLIS
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -142,6 +239,10 @@ class HotspotViewModel(application: Application) : AndroidViewModel(application)
|
||||
sealed class HotspotState {
|
||||
object Intro : HotspotState()
|
||||
object Starting : HotspotState()
|
||||
|
||||
/** A foreign Wi-Fi Direct group is up; waiting for the user's go-ahead. */
|
||||
object ConfirmDisconnect : HotspotState()
|
||||
|
||||
data class Active(
|
||||
val ssid: String,
|
||||
val password: String,
|
||||
|
||||
@ -528,4 +528,11 @@ class SecureIdentityStateManager {
|
||||
}
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
/** Use for panic paths that must finish the disk mutation before identity reset continues. */
|
||||
fun clearSecureValuesSynchronously(vararg keys: String): Boolean {
|
||||
val editor = prefs.edit()
|
||||
keys.forEach(editor::remove)
|
||||
return editor.commit()
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,8 +68,17 @@ class BluetoothConnectionManager(
|
||||
}
|
||||
|
||||
override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) {
|
||||
packetBroadcaster.onLinkDisconnected(device.address, linkID)
|
||||
delegate?.onDeviceDisconnected(device, linkID, peerID)
|
||||
}
|
||||
|
||||
override fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) {
|
||||
packetBroadcaster.onGattClientWriteComplete(deviceAddress, linkID, status)
|
||||
}
|
||||
|
||||
override fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) {
|
||||
packetBroadcaster.onGattServerNotificationComplete(deviceAddress, linkID, status)
|
||||
}
|
||||
|
||||
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
|
||||
delegate?.onRSSIUpdated(deviceAddress, rssi)
|
||||
@ -485,4 +494,6 @@ interface BluetoothConnectionManagerDelegate {
|
||||
fun onDeviceConnected(device: BluetoothDevice)
|
||||
fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?)
|
||||
fun onRSSIUpdated(deviceAddress: String, rssi: Int)
|
||||
fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) = Unit
|
||||
fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) = Unit
|
||||
}
|
||||
|
||||
@ -95,9 +95,6 @@ class BluetoothGattClientManager(
|
||||
private var scanWatchdogJob: Job? = null
|
||||
private var scanDutyCycleJob: Job? = null
|
||||
|
||||
// RSSI monitoring state
|
||||
private var rssiMonitoringJob: Job? = null
|
||||
|
||||
// State management
|
||||
private var isActive = false
|
||||
|
||||
@ -133,8 +130,6 @@ class BluetoothGattClientManager(
|
||||
|
||||
connectionScope.launch {
|
||||
applyPowerProfile(powerManager.profile.value)
|
||||
// Start RSSI monitoring
|
||||
startRSSIMonitoring()
|
||||
}
|
||||
|
||||
return true
|
||||
@ -151,7 +146,6 @@ class BluetoothGattClientManager(
|
||||
if (!isActive) {
|
||||
// Idempotent stop
|
||||
stopScanning()
|
||||
stopRSSIMonitoring()
|
||||
return
|
||||
}
|
||||
|
||||
@ -167,7 +161,6 @@ class BluetoothGattClientManager(
|
||||
} catch (_: Exception) { }
|
||||
|
||||
stopScanning()
|
||||
stopRSSIMonitoring()
|
||||
Log.i(TAG, "GATT client manager stopped")
|
||||
}
|
||||
}
|
||||
@ -185,40 +178,6 @@ class BluetoothGattClientManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic RSSI monitoring for all client connections
|
||||
*/
|
||||
private fun startRSSIMonitoring() {
|
||||
rssiMonitoringJob?.cancel()
|
||||
rssiMonitoringJob = connectionScope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
// Request RSSI from all client connections
|
||||
val connectedDevices = connectionTracker.getConnectedDevices()
|
||||
connectedDevices.values.filter { it.isClient && it.gatt != null }.forEach { deviceConn ->
|
||||
try {
|
||||
deviceConn.gatt?.readRemoteRssi()
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}")
|
||||
}
|
||||
}
|
||||
delay(powerManager.profile.value.ble.rssiPollIntervalMs)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error in RSSI monitoring: ${e.message}")
|
||||
delay(powerManager.profile.value.ble.rssiPollIntervalMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop RSSI monitoring
|
||||
*/
|
||||
private fun stopRSSIMonitoring() {
|
||||
rssiMonitoringJob?.cancel()
|
||||
rssiMonitoringJob = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Start scanning with rate limiting
|
||||
*/
|
||||
@ -616,18 +575,17 @@ class BluetoothGattClientManager(
|
||||
Log.d(TAG, "Failed to parse packet from ${gatt.device.address}, size: ${value.size} bytes")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
|
||||
val deviceAddress = gatt.device.address
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
// Update the connection tracker with new RSSI value
|
||||
connectionTracker.updateDeviceConnectionIfCurrent(deviceAddress, linkID) {
|
||||
it.copy(rssi = rssi)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Failed to read RSSI for $deviceAddress, status: $status")
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: Int
|
||||
) {
|
||||
if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) {
|
||||
delegate?.onGattClientWriteComplete(gatt.device.address, linkID, status)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@ -297,6 +297,14 @@ class BluetoothGattServerManager(
|
||||
gattServer?.sendResponse(device, requestId, BluetoothGatt.GATT_SUCCESS, 0, null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationSent(device: BluetoothDevice, status: Int) {
|
||||
delegate?.onGattServerNotificationComplete(
|
||||
device.address,
|
||||
serverLinkIDs[device.address],
|
||||
status
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Proper cleanup sequencing to prevent race conditions
|
||||
|
||||
@ -20,6 +20,7 @@ import com.bitchat.android.util.toHexString
|
||||
import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import java.util.*
|
||||
import kotlin.math.sign
|
||||
import kotlin.random.Random
|
||||
@ -111,12 +112,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
|
||||
private val packetProcessor = PacketProcessor(myPeerID)
|
||||
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
|
||||
private val voiceFrameQueue = Channel<VoiceFrameRequest>(capacity = 128)
|
||||
private lateinit var gossipSyncManager: GossipSyncManager
|
||||
// Service-level notification manager for background (no-UI) DMs
|
||||
private val serviceNotificationManager = com.bitchat.android.ui.NotificationManager(
|
||||
context.applicationContext,
|
||||
androidx.core.app.NotificationManagerCompat.from(context.applicationContext),
|
||||
com.bitchat.android.util.NotificationIntervalManager()
|
||||
androidx.core.app.NotificationManagerCompat.from(context.applicationContext)
|
||||
)
|
||||
|
||||
// Service state management
|
||||
@ -130,6 +132,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
private var terminated = false
|
||||
|
||||
init {
|
||||
serviceScope.launch {
|
||||
for (request in voiceFrameQueue) dispatchVoiceFrame(request)
|
||||
}
|
||||
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
|
||||
VerificationService.configure(encryptionService)
|
||||
setupDelegates()
|
||||
@ -480,21 +485,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Callbacks
|
||||
override fun onMessageReceived(message: BitchatMessage) {
|
||||
// Always reflect into process-wide store so UI can hydrate after recreation
|
||||
try {
|
||||
when {
|
||||
message.isPrivate -> {
|
||||
val peer = message.senderPeerID ?: ""
|
||||
if (peer.isNotEmpty()) com.bitchat.android.services.AppStateStore.addPrivateMessage(peer, message)
|
||||
}
|
||||
message.channel != null -> {
|
||||
com.bitchat.android.services.AppStateStore.addChannelMessage(message.channel!!, message)
|
||||
}
|
||||
else -> {
|
||||
com.bitchat.android.services.AppStateStore.addPublicMessage(message)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
// Private-message admission is authoritative. In particular, do not forward a
|
||||
// callback or notify after panic mode rejected the message while wiping state.
|
||||
if (
|
||||
!com.bitchat.android.services.IncomingMessageAdmission
|
||||
.admitToAppState(message)
|
||||
) return
|
||||
|
||||
// And forward to UI delegate if attached
|
||||
delegate?.didReceiveMessage(message)
|
||||
|
||||
@ -616,6 +613,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
|
||||
messageHandler.handlePublicVoiceFrame(routed)
|
||||
|
||||
override fun handleLeave(routed: RoutedPacket) {
|
||||
serviceScope.launch { messageHandler.handleLeave(routed) }
|
||||
@ -958,6 +958,44 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
}
|
||||
|
||||
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
|
||||
if (payload.isEmpty()) return
|
||||
voiceFrameQueue.trySend(VoiceFrameRequest(recipientPeerID, payload.copyOf()))
|
||||
}
|
||||
|
||||
private fun dispatchVoiceFrame(request: VoiceFrameRequest) {
|
||||
try {
|
||||
val recipientPeerID = request.recipientPeerID
|
||||
val packet = if (recipientPeerID == null) {
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.VOICE_FRAME.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = request.payload,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
} else {
|
||||
if (!encryptionService.hasEstablishedSession(recipientPeerID)) return
|
||||
val plaintext = NoisePayload(NoisePayloadType.VOICE_FRAME, request.payload).encode()
|
||||
val ciphertext = encryptionService.encrypt(plaintext, recipientPeerID)
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = ciphertext,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
}
|
||||
broadcastRoutedPacket(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Live voice frame send failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: com.bitchat.android.model.BitchatFilePacket,
|
||||
@ -1592,6 +1630,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
securityManager.clearAllData()
|
||||
peerManager.clearAllPeers()
|
||||
peerManager.clearAllFingerprints()
|
||||
try { gossipSyncManager.clear() } catch (_: Exception) { }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error clearing mesh service internal data: ${e.message}")
|
||||
}
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattServer
|
||||
import android.bluetooth.BluetoothStatusCodes
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
@ -19,8 +22,8 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.actor
|
||||
import java.util.ArrayDeque
|
||||
|
||||
/**
|
||||
* Handles packet broadcasting to connected devices using actor pattern for serialization
|
||||
@ -48,7 +51,10 @@ class BluetoothPacketBroadcaster(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BluetoothPacketBroadcaster"
|
||||
private const val CLEANUP_DELAY = com.bitchat.android.util.AppConstants.Mesh.BROADCAST_CLEANUP_DELAY_MS
|
||||
private const val MAX_PENDING_SENDS_PER_LINK = 256
|
||||
private const val MAX_PENDING_BYTES_PER_LINK = 1_048_576
|
||||
private const val SEND_RETRY_DELAY_MS = 15L
|
||||
private const val MAX_CALLBACK_RETRIES = 3
|
||||
}
|
||||
|
||||
// Optional nickname resolver injected by higher layer (peerID -> nickname?)
|
||||
@ -119,11 +125,38 @@ class BluetoothPacketBroadcaster(
|
||||
// Actor scope for the broadcaster
|
||||
private val broadcasterScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val fragmentingSender = FragmentingPacketSender(connectionScope, fragmentManager, TAG)
|
||||
|
||||
private enum class SendDirection { CLIENT_WRITE, SERVER_NOTIFICATION }
|
||||
|
||||
private data class SendKey(
|
||||
val deviceAddress: String,
|
||||
val linkID: String,
|
||||
val direction: SendDirection
|
||||
)
|
||||
|
||||
private data class PendingSend(
|
||||
val data: ByteArray,
|
||||
val device: BluetoothDevice,
|
||||
val gatt: BluetoothGatt? = null,
|
||||
val gattServer: BluetoothGattServer? = null,
|
||||
val characteristic: BluetoothGattCharacteristic,
|
||||
var callbackFailures: Int = 0
|
||||
)
|
||||
|
||||
private class LinkSendState {
|
||||
val pending = ArrayDeque<PendingSend>()
|
||||
var pendingBytes = 0
|
||||
var inFlight = false
|
||||
var retryScheduled = false
|
||||
}
|
||||
|
||||
private val sendLock = Any()
|
||||
private val sendStates = mutableMapOf<SendKey, LinkSendState>()
|
||||
|
||||
// SERIALIZATION: Actor to serialize all broadcast operations
|
||||
@OptIn(kotlinx.coroutines.ObsoleteCoroutinesApi::class)
|
||||
private val broadcasterActor = broadcasterScope.actor<BroadcastRequest>(
|
||||
capacity = Channel.UNLIMITED
|
||||
capacity = 256
|
||||
) {
|
||||
for (request in channel) {
|
||||
val accepted = try {
|
||||
@ -443,21 +476,16 @@ class BluetoothPacketBroadcaster(
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
return try {
|
||||
characteristic?.let { char ->
|
||||
char.value = data
|
||||
val result = gattServer?.notifyCharacteristicChanged(device, char, false) ?: false
|
||||
result
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}")
|
||||
connectionScope.launch {
|
||||
delay(CLEANUP_DELAY)
|
||||
connectionTracker.removeSubscribedDevice(device)
|
||||
connectionTracker.addressPeerMap.remove(device.address)
|
||||
}
|
||||
false
|
||||
}
|
||||
val server = gattServer ?: return false
|
||||
val char = characteristic ?: return false
|
||||
val linkID = connectionTracker.getDeviceConnection(device.address)
|
||||
?.takeIf { !it.isClient }
|
||||
?.linkID
|
||||
?: return false
|
||||
return enqueueSend(
|
||||
SendKey(device.address, linkID, SendDirection.SERVER_NOTIFICATION),
|
||||
PendingSend(data.copyOf(), device, gattServer = server, characteristic = char)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -467,20 +495,160 @@ class BluetoothPacketBroadcaster(
|
||||
deviceConn: BluetoothConnectionTracker.DeviceConnection,
|
||||
data: ByteArray
|
||||
): Boolean {
|
||||
return try {
|
||||
deviceConn.characteristic?.let { char ->
|
||||
char.value = data
|
||||
val result = deviceConn.gatt?.writeCharacteristic(char) ?: false
|
||||
result
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error sending to client connection ${deviceConn.device.address}: ${e.message}")
|
||||
connectionScope.launch {
|
||||
delay(CLEANUP_DELAY)
|
||||
connectionTracker.cleanupDeviceConnection(deviceConn.device.address)
|
||||
val gatt = deviceConn.gatt ?: return false
|
||||
val char = deviceConn.characteristic ?: return false
|
||||
return enqueueSend(
|
||||
SendKey(deviceConn.device.address, deviceConn.linkID, SendDirection.CLIENT_WRITE),
|
||||
PendingSend(data.copyOf(), deviceConn.device, gatt = gatt, characteristic = char)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Android permits only one outstanding GATT operation per link. Queueing here mirrors the
|
||||
* readiness-driven iOS transport and prevents later voice frames from overwriting an operation
|
||||
* that the controller has not completed yet.
|
||||
*/
|
||||
private fun enqueueSend(key: SendKey, request: PendingSend): Boolean {
|
||||
val startNow = synchronized(sendLock) {
|
||||
val state = sendStates.getOrPut(key, ::LinkSendState)
|
||||
if (
|
||||
state.pending.size >= MAX_PENDING_SENDS_PER_LINK ||
|
||||
state.pendingBytes + request.data.size > MAX_PENDING_BYTES_PER_LINK
|
||||
) {
|
||||
Log.w(TAG, "BLE send queue full for ${key.direction}; rejecting ${request.data.size} bytes")
|
||||
return false
|
||||
}
|
||||
state.pending.addLast(request)
|
||||
state.pendingBytes += request.data.size
|
||||
if (!state.inFlight && !state.retryScheduled) {
|
||||
state.inFlight = true
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
if (startNow) startHead(key)
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@SuppressLint("MissingPermission", "ObsoleteSdkInt")
|
||||
private fun startHead(key: SendKey) {
|
||||
val request = synchronized(sendLock) { sendStates[key]?.pending?.peekFirst() } ?: return
|
||||
val accepted = try {
|
||||
when (key.direction) {
|
||||
SendDirection.CLIENT_WRITE -> {
|
||||
val gatt = request.gatt
|
||||
if (gatt == null) {
|
||||
false
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
gatt.writeCharacteristic(
|
||||
request.characteristic,
|
||||
request.data,
|
||||
BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
) == BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
request.characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||
request.characteristic.value = request.data
|
||||
gatt.writeCharacteristic(request.characteristic)
|
||||
}
|
||||
}
|
||||
SendDirection.SERVER_NOTIFICATION -> {
|
||||
val server = request.gattServer
|
||||
if (server == null) {
|
||||
false
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
server.notifyCharacteristicChanged(
|
||||
request.device,
|
||||
request.characteristic,
|
||||
false,
|
||||
request.data
|
||||
) == BluetoothStatusCodes.SUCCESS
|
||||
} else {
|
||||
request.characteristic.value = request.data
|
||||
server.notifyCharacteristicChanged(request.device, request.characteristic, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "BLE ${key.direction} failed to start: ${error.message}")
|
||||
false
|
||||
}
|
||||
if (!accepted) rejectStart(key)
|
||||
}
|
||||
|
||||
private fun rejectStart(key: SendKey): Boolean {
|
||||
val schedule = synchronized(sendLock) {
|
||||
val state = sendStates[key] ?: return false
|
||||
state.inFlight = false
|
||||
if (state.retryScheduled || state.pending.isEmpty()) false else {
|
||||
state.retryScheduled = true
|
||||
true
|
||||
}
|
||||
}
|
||||
if (schedule) {
|
||||
connectionScope.launch {
|
||||
delay(SEND_RETRY_DELAY_MS)
|
||||
val retry = synchronized(sendLock) {
|
||||
val state = sendStates[key] ?: return@synchronized false
|
||||
state.retryScheduled = false
|
||||
if (!state.inFlight && state.pending.isNotEmpty()) {
|
||||
state.inFlight = true
|
||||
true
|
||||
} else false
|
||||
}
|
||||
if (retry) startHead(key)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) {
|
||||
completeSend(SendKey(deviceAddress, linkID, SendDirection.CLIENT_WRITE), status)
|
||||
}
|
||||
|
||||
fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) {
|
||||
if (linkID == null) return
|
||||
completeSend(SendKey(deviceAddress, linkID, SendDirection.SERVER_NOTIFICATION), status)
|
||||
}
|
||||
|
||||
private fun completeSend(key: SendKey, status: Int) {
|
||||
var retry = false
|
||||
val startNext = synchronized(sendLock) {
|
||||
val state = sendStates[key] ?: return
|
||||
val head = state.pending.peekFirst() ?: run {
|
||||
sendStates.remove(key)
|
||||
return
|
||||
}
|
||||
state.inFlight = false
|
||||
if (status != BluetoothGatt.GATT_SUCCESS && head.callbackFailures < MAX_CALLBACK_RETRIES) {
|
||||
head.callbackFailures++
|
||||
retry = true
|
||||
false
|
||||
} else {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.w(TAG, "BLE ${key.direction} failed with status $status after retries")
|
||||
}
|
||||
state.pending.removeFirst()
|
||||
state.pendingBytes -= head.data.size
|
||||
if (state.pending.isEmpty()) {
|
||||
sendStates.remove(key)
|
||||
false
|
||||
} else {
|
||||
state.inFlight = true
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (retry) rejectStart(key) else if (startNext) startHead(key)
|
||||
}
|
||||
|
||||
fun onLinkDisconnected(deviceAddress: String, linkID: String?) {
|
||||
synchronized(sendLock) {
|
||||
sendStates.keys.removeAll { key ->
|
||||
key.deviceAddress == deviceAddress && (linkID == null || key.linkID == linkID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -499,6 +667,7 @@ class BluetoothPacketBroadcaster(
|
||||
* Shutdown the broadcaster actor gracefully
|
||||
*/
|
||||
fun shutdown() {
|
||||
synchronized(sendLock) { sendStates.clear() }
|
||||
// Close the actor gracefully
|
||||
broadcasterActor.close()
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ import com.bitchat.android.service.TransportBridgeService
|
||||
import com.bitchat.android.sync.GossipSyncManager
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@ -41,7 +42,11 @@ class MeshCore(
|
||||
private val hooks: Hooks = Hooks()
|
||||
) {
|
||||
data class Hooks(
|
||||
val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
|
||||
/**
|
||||
* Reflects a decoded message into transport-owned state before delegate dispatch.
|
||||
* Return false to suppress all downstream effects for a rejected message.
|
||||
*/
|
||||
val onMessageReceived: ((BitchatMessage) -> Boolean)? = null,
|
||||
val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
|
||||
val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
|
||||
val onReadReceiptSent: ((String) -> Unit)? = null,
|
||||
@ -107,6 +112,8 @@ class MeshCore(
|
||||
private val storeForwardManager = StoreForwardManager()
|
||||
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
|
||||
private val packetProcessor = PacketProcessor(myPeerID)
|
||||
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
|
||||
private val voiceFrameQueue = Channel<VoiceFrameRequest>(capacity = 128)
|
||||
private val directPeers = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
val gossipSyncManager: GossipSyncManager =
|
||||
@ -118,6 +125,9 @@ class MeshCore(
|
||||
private var isActive = false
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
for (request in voiceFrameQueue) dispatchVoiceFrame(request)
|
||||
}
|
||||
messageHandler.packetProcessor = packetProcessor
|
||||
peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) }
|
||||
setupDelegates()
|
||||
@ -392,7 +402,7 @@ class MeshCore(
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: BitchatMessage) {
|
||||
hooks.onMessageReceived?.invoke(message)
|
||||
if (hooks.onMessageReceived?.invoke(message) == false) return
|
||||
delegate?.didReceiveMessage(message)
|
||||
}
|
||||
|
||||
@ -477,6 +487,9 @@ class MeshCore(
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun handleVoiceFrame(routed: RoutedPacket): Boolean =
|
||||
messageHandler.handlePublicVoiceFrame(routed)
|
||||
|
||||
override fun handleLeave(routed: RoutedPacket) {
|
||||
scope.launch { messageHandler.handleLeave(routed) }
|
||||
}
|
||||
@ -607,6 +620,44 @@ class MeshCore(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
|
||||
if (payload.isEmpty()) return
|
||||
voiceFrameQueue.trySend(VoiceFrameRequest(recipientPeerID, payload.copyOf()))
|
||||
}
|
||||
|
||||
private fun dispatchVoiceFrame(request: VoiceFrameRequest) {
|
||||
try {
|
||||
val recipientPeerID = request.recipientPeerID
|
||||
val packet = if (recipientPeerID == null) {
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.VOICE_FRAME.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = request.payload,
|
||||
ttl = maxTtl
|
||||
)
|
||||
} else {
|
||||
if (!encryptionService.hasEstablishedSession(recipientPeerID)) return
|
||||
val plaintext = NoisePayload(NoisePayloadType.VOICE_FRAME, request.payload).encode()
|
||||
val ciphertext = encryptionService.encrypt(plaintext, recipientPeerID)
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = ciphertext,
|
||||
ttl = maxTtl
|
||||
)
|
||||
}
|
||||
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
} catch (e: Exception) {
|
||||
Log.w("MeshCore", "Live voice frame send failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
@ -983,6 +1034,7 @@ class MeshCore(
|
||||
securityManager.clearAllData()
|
||||
peerManager.clearAllPeers()
|
||||
peerManager.clearAllFingerprints()
|
||||
try { gossipSyncManager.clear() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
fun clearAllEncryptionData() {
|
||||
|
||||
@ -21,6 +21,7 @@ interface MeshService {
|
||||
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
|
||||
fun sendFileBroadcast(file: BitchatFilePacket)
|
||||
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
|
||||
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray)
|
||||
fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
|
||||
@ -10,6 +10,8 @@ import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.sync.PacketIdUtil
|
||||
import com.bitchat.android.util.toHexString
|
||||
import com.bitchat.android.features.voice.LiveVoiceManager
|
||||
import com.bitchat.android.features.voice.LiveVoiceScope
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
|
||||
@ -140,7 +142,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
senderPeerID = peerID
|
||||
)
|
||||
|
||||
delegate?.onMessageReceived(message)
|
||||
if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) {
|
||||
delegate?.onMessageReceived(message)
|
||||
}
|
||||
|
||||
// Send delivery ACK with generated message ID
|
||||
sendDeliveryAck(uniqueMsgId, peerID)
|
||||
@ -149,6 +153,16 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
}
|
||||
}
|
||||
|
||||
com.bitchat.android.model.NoisePayloadType.VOICE_FRAME -> {
|
||||
return LiveVoiceManager.getInstance(appContext).handleFrame(
|
||||
peerID = peerID,
|
||||
nickname = delegate?.getPeerNickname(peerID) ?: peerID,
|
||||
scope = LiveVoiceScope.DIRECT_MESSAGE,
|
||||
payload = noisePayload.data,
|
||||
timestampMs = packet.timestamp.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
com.bitchat.android.model.NoisePayloadType.PEER_STATE -> {
|
||||
val authenticatedState = AuthenticatedPeerState.decode(noisePayload.data)
|
||||
if (authenticatedState == null) {
|
||||
@ -411,6 +425,27 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
}
|
||||
// Message relay is now handled by centralized PacketRelayManager
|
||||
}
|
||||
|
||||
/** Validate and ingest an ephemeral public push-to-talk frame. */
|
||||
fun handlePublicVoiceFrame(routed: RoutedPacket): Boolean {
|
||||
val packet = routed.packet
|
||||
val peerID = routed.peerID ?: return false
|
||||
if (peerID == myPeerID) return true
|
||||
val recipient = packet.recipientID
|
||||
if (recipient != null && !recipient.contentEquals(delegate?.getBroadcastRecipient())) return false
|
||||
if (packet.timestamp > Long.MAX_VALUE.toULong()) return false
|
||||
val ageMs = System.currentTimeMillis() - packet.timestamp.toLong()
|
||||
if (ageMs !in -30_000L..30_000L) return false
|
||||
val peerInfo = delegate?.getPeerInfo(peerID)
|
||||
if (peerInfo == null || !peerInfo.isVerifiedNickname) return false
|
||||
return LiveVoiceManager.getInstance(appContext).handleFrame(
|
||||
peerID = peerID,
|
||||
nickname = delegate?.getPeerNickname(peerID) ?: peerID,
|
||||
scope = LiveVoiceScope.PUBLIC_MESH,
|
||||
payload = packet.payload,
|
||||
timestampMs = packet.timestamp.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle broadcast message with verification enforcement
|
||||
@ -441,7 +476,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
senderPeerID = peerID,
|
||||
timestamp = Date(packet.timestamp.toLong())
|
||||
)
|
||||
delegate?.onMessageReceived(message)
|
||||
if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) {
|
||||
delegate?.onMessageReceived(message)
|
||||
}
|
||||
return
|
||||
} else if (isFileTransfer) {
|
||||
Log.w(TAG, "FILE_TRANSFER decode failed (broadcast) from ${peerID.take(8)}")
|
||||
@ -501,7 +538,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
recipientNickname = delegate?.getMyNickname()
|
||||
)
|
||||
Log.d(TAG, "📄 Saved incoming file to $savedPath")
|
||||
delegate?.onMessageReceived(message)
|
||||
if (!LiveVoiceManager.getInstance(appContext).absorbFinalizedVoiceNote(message)) {
|
||||
delegate?.onMessageReceived(message)
|
||||
}
|
||||
return
|
||||
} else if (isFileTransfer) {
|
||||
Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (private) from ${peerID.take(8)} payloadSize=${packet.payload.size}")
|
||||
|
||||
@ -136,6 +136,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed)
|
||||
MessageType.MESSAGE -> handleMessage(routed)
|
||||
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
|
||||
MessageType.VOICE_FRAME -> validPacket = delegate?.handleVoiceFrame(routed) ?: false
|
||||
MessageType.LEAVE -> handleLeave(routed)
|
||||
MessageType.FRAGMENT -> handleFragment(routed)
|
||||
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
|
||||
@ -296,6 +297,7 @@ interface PacketProcessorDelegate {
|
||||
fun handleNoiseEncrypted(routed: RoutedPacket): Boolean
|
||||
suspend fun handleAnnounce(routed: RoutedPacket): Boolean
|
||||
fun handleMessage(routed: RoutedPacket)
|
||||
fun handleVoiceFrame(routed: RoutedPacket): Boolean = false
|
||||
fun handleLeave(routed: RoutedPacket)
|
||||
fun handleFragment(packet: BitchatPacket): BitchatPacket?
|
||||
fun handleRequestSync(routed: RoutedPacket)
|
||||
|
||||
@ -62,8 +62,16 @@ class PacketRelayManager(private val myPeerID: String) {
|
||||
}
|
||||
|
||||
// Decrement TTL by 1
|
||||
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
||||
val networkSize = delegate?.getNetworkSize() ?: 1
|
||||
val decrementedTtl = (packet.ttl - 1u).toUByte()
|
||||
val voiceTtl = if (
|
||||
MessageType.fromValue(packet.type) == MessageType.VOICE_FRAME && networkSize > 6
|
||||
) minOf(decrementedTtl, 5u.toUByte()) else decrementedTtl
|
||||
val relayPacket = packet.copy(ttl = voiceTtl)
|
||||
Log.d(TAG, "Decremented TTL from ${packet.ttl} to ${relayPacket.ttl}")
|
||||
if (MessageType.fromValue(packet.type) == MessageType.VOICE_FRAME) {
|
||||
delay(Random.nextLong(8L, 26L))
|
||||
}
|
||||
|
||||
// Source-based routing: if route is set and includes us, try targeted next-hop forwarding
|
||||
val route = relayPacket.route
|
||||
|
||||
@ -335,7 +335,7 @@ class PeerManager {
|
||||
|
||||
// Remove stale peer IDs
|
||||
stalePeerIDs.forEach { stalePeerID ->
|
||||
removePeer(stalePeerID, notifyDelegate = false)
|
||||
removePeer(stalePeerID, notifyPeerList = false)
|
||||
}
|
||||
|
||||
// Check if this is a new peer announcement
|
||||
@ -371,7 +371,7 @@ class PeerManager {
|
||||
/**
|
||||
* Remove peer
|
||||
*/
|
||||
fun removePeer(peerID: String, notifyDelegate: Boolean = true) {
|
||||
fun removePeer(peerID: String, notifyPeerList: Boolean = true) {
|
||||
val removed = peers.remove(peerID)
|
||||
peerRSSI.remove(peerID)
|
||||
announcedPeers.remove(peerID)
|
||||
@ -380,10 +380,13 @@ class PeerManager {
|
||||
// Also remove fingerprint mappings
|
||||
fingerprintManager.removePeer(peerID)
|
||||
|
||||
if (notifyDelegate && removed != null) {
|
||||
// Notify specific removal event then list update
|
||||
if (removed != null) {
|
||||
// Lifecycle cleanup must always run. Callers may suppress only the
|
||||
// intermediate peer-list update while atomically replacing a peer.
|
||||
try { delegate?.onPeerRemoved(peerID) } catch (_: Exception) {}
|
||||
notifyPeerListUpdate()
|
||||
if (notifyPeerList) {
|
||||
notifyPeerListUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -46,7 +46,6 @@ class PowerManager private constructor(context: Context) : LifecycleEventObserve
|
||||
val scanOnMs: Long,
|
||||
val scanOffMs: Long,
|
||||
val continuousScan: Boolean,
|
||||
val rssiPollIntervalMs: Long,
|
||||
val maxConnections: Int = 8
|
||||
)
|
||||
|
||||
@ -318,24 +317,22 @@ internal object PowerProfileResolver {
|
||||
PowerManager.BleSchedule(
|
||||
scanOnMs = 1_000L,
|
||||
scanOffMs = 29_000L,
|
||||
continuousScan = false,
|
||||
rssiPollIntervalMs = backgroundRssiInterval(batteryBand)
|
||||
continuousScan = false
|
||||
)
|
||||
isBackground ->
|
||||
PowerManager.BleSchedule(
|
||||
scanOnMs = 1_000L,
|
||||
scanOffMs = 59_000L,
|
||||
continuousScan = false,
|
||||
rssiPollIntervalMs = backgroundRssiInterval(batteryBand)
|
||||
continuousScan = false
|
||||
)
|
||||
mode == PowerManager.PowerMode.PERFORMANCE ->
|
||||
PowerManager.BleSchedule(Long.MAX_VALUE, 0L, true, 5_000L)
|
||||
PowerManager.BleSchedule(Long.MAX_VALUE, 0L, true)
|
||||
mode == PowerManager.PowerMode.BALANCED ->
|
||||
PowerManager.BleSchedule(8_000L, 2_000L, false, 10_000L)
|
||||
PowerManager.BleSchedule(8_000L, 2_000L, false)
|
||||
mode == PowerManager.PowerMode.POWER_SAVER ->
|
||||
PowerManager.BleSchedule(2_000L, 28_000L, false, 30_000L)
|
||||
PowerManager.BleSchedule(2_000L, 28_000L, false)
|
||||
else ->
|
||||
PowerManager.BleSchedule(1_000L, 29_000L, false, 60_000L)
|
||||
PowerManager.BleSchedule(1_000L, 29_000L, false)
|
||||
}
|
||||
|
||||
val announcementInterval = when {
|
||||
@ -407,10 +404,4 @@ internal object PowerProfileResolver {
|
||||
discoverySessionRefreshMinMs = minRefreshMinutes * 60_000L,
|
||||
discoveryStaleMs = staleMinutes * 60_000L
|
||||
)
|
||||
|
||||
private fun backgroundRssiInterval(band: PowerManager.BatteryBand): Long = when (band) {
|
||||
PowerManager.BatteryBand.NORMAL -> 60_000L
|
||||
PowerManager.BatteryBand.LOW -> 120_000L
|
||||
PowerManager.BatteryBand.CRITICAL -> 300_000L
|
||||
}
|
||||
}
|
||||
|
||||
@ -268,6 +268,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
MessageType.ANNOUNCE,
|
||||
MessageType.MESSAGE,
|
||||
MessageType.FILE_TRANSFER,
|
||||
MessageType.VOICE_FRAME,
|
||||
MessageType.LEAVE
|
||||
)) {
|
||||
return true
|
||||
|
||||
@ -167,6 +167,23 @@ class UnifiedMeshService(
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray) {
|
||||
if (recipientPeerID == null) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.sendVoiceFrame(null, payload)
|
||||
else -> wifiService()?.sendVoiceFrame(null, payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
when {
|
||||
isBleReady(recipientPeerID) -> bluetooth.sendVoiceFrame(recipientPeerID, payload)
|
||||
isWifiReady(recipientPeerID) -> wifiService()?.sendVoiceFrame(recipientPeerID, payload)
|
||||
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
|
||||
bluetooth.sendVoiceFrame(recipientPeerID, payload)
|
||||
else -> wifiService()?.sendVoiceFrame(recipientPeerID, payload)
|
||||
}
|
||||
}
|
||||
|
||||
override fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
|
||||
@ -14,6 +14,13 @@ import java.nio.ByteOrder
|
||||
* Length field for TLV is 2 bytes (UInt16, big-endian) for all TLVs.
|
||||
* For large files, CONTENT is chunked into multiple TLVs of up to 65535 bytes each.
|
||||
*
|
||||
* Unknown TLV types are SKIPPED, not rejected: the tag list above is a floor,
|
||||
* not a ceiling, and a decoder that bails on the first tag it does not know
|
||||
* makes the format unextendable — the whole file is lost over a field that,
|
||||
* by construction, the sender considered optional. The iOS client has always
|
||||
* done this (`case nil: continue` in its own decoder), so rejecting here also
|
||||
* meant the two implementations disagreed about what a valid packet is.
|
||||
*
|
||||
* Note: The outer BitchatPacket uses version 2 (4-byte payload length), so this
|
||||
* TLV payload can exceed 64 KiB even though each TLV value is limited to 65535 bytes.
|
||||
* Transport-level fragmentation then splits the final packet for BLE MTU.
|
||||
@ -26,7 +33,15 @@ data class BitchatFilePacket(
|
||||
) {
|
||||
private enum class TLVType(val v: UByte) {
|
||||
FILE_NAME(0x01u), FILE_SIZE(0x02u), MIME_TYPE(0x03u), CONTENT(0x04u);
|
||||
companion object { fun from(value: UByte) = values().find { it.v == value } }
|
||||
companion object {
|
||||
fun from(value: UByte): TLVType? = when (value) {
|
||||
FILE_NAME.v -> FILE_NAME
|
||||
FILE_SIZE.v -> FILE_SIZE
|
||||
MIME_TYPE.v -> MIME_TYPE
|
||||
CONTENT.v -> CONTENT
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun encode(): ByteArray? {
|
||||
@ -90,8 +105,15 @@ data class BitchatFilePacket(
|
||||
var size: Long? = null
|
||||
var mime: String? = null
|
||||
var contentBytes: ByteArray? = null
|
||||
while (off + 3 <= data.size) { // minimum TLV header size (type + 2 bytes length)
|
||||
val t = TLVType.from(data[off].toUByte()) ?: return null
|
||||
var skippedUnknownTLVs = 0
|
||||
while (off < data.size) {
|
||||
// Every TLV needs at least a type and a 2-byte length.
|
||||
// Reject a truncated trailing header instead of silently
|
||||
// accepting it, matching the iOS decoder.
|
||||
if (data.size - off < 3) return null
|
||||
// A null `t` is an unknown tag: read its length like any
|
||||
// other 2-byte TLV and skip its value, matching iOS.
|
||||
val t = TLVType.from(data[off].toUByte())
|
||||
off += 1
|
||||
// CONTENT uses 4-byte length; others use 2-byte length
|
||||
val len: Int
|
||||
@ -105,6 +127,18 @@ data class BitchatFilePacket(
|
||||
off += 2
|
||||
}
|
||||
if (len < 0 || off + len > data.size) return null
|
||||
if (t == null) {
|
||||
// Unknown tag: advance past the value without copying it
|
||||
// and without logging. A peer can pad a packet with
|
||||
// zero-length unknown TLVs — 3 bytes each — so anything
|
||||
// per-TLV here is attacker-scaled: at the payload
|
||||
// ceiling that is millions of copies and formatted log
|
||||
// lines monopolising the mesh handler. Counted and
|
||||
// reported once after the loop instead.
|
||||
off += len
|
||||
skippedUnknownTLVs += 1
|
||||
continue
|
||||
}
|
||||
val value = data.copyOfRange(off, off + len)
|
||||
off += len
|
||||
when (t) {
|
||||
@ -124,6 +158,9 @@ data class BitchatFilePacket(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (skippedUnknownTLVs > 0) {
|
||||
android.util.Log.d("BitchatFilePacket", "⏭️ Skipped $skippedUnknownTLVs unknown TLV(s)")
|
||||
}
|
||||
val n = name ?: return null
|
||||
val c = contentBytes ?: return null
|
||||
val s = size ?: c.size.toLong()
|
||||
@ -138,4 +175,3 @@ data class BitchatFilePacket(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ enum class NoisePayloadType(val value: UByte) {
|
||||
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
|
||||
READ_RECEIPT(0x02u), // Message was read
|
||||
DELIVERED(0x03u), // Message was delivered
|
||||
VOICE_FRAME(0x08u), // Ephemeral live push-to-talk frame
|
||||
VERIFY_CHALLENGE(0x10u), // Verification challenge
|
||||
VERIFY_RESPONSE(0x11u), // Verification response
|
||||
FILE_TRANSFER(0x20u),
|
||||
|
||||
@ -29,9 +29,23 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
|
||||
companion object {
|
||||
val NONE = PeerCapabilities(0)
|
||||
|
||||
val PREKEYS = PeerCapabilities(1L shl 0)
|
||||
val WIFI_BULK = PeerCapabilities(1L shl 1)
|
||||
val GATEWAY = PeerCapabilities(1L shl 2)
|
||||
val GROUPS = PeerCapabilities(1L shl 3)
|
||||
val BOARD = PeerCapabilities(1L shl 4)
|
||||
val VOUCH = PeerCapabilities(1L shl 5)
|
||||
val MESH_DIAGNOSTICS = PeerCapabilities(1L shl 6)
|
||||
val BRIDGE = PeerCapabilities(1L shl 7)
|
||||
|
||||
/** Noise-encrypted private BitchatFilePacket using payload type 0x20. */
|
||||
val PRIVATE_MEDIA = PeerCapabilities(1L shl 8)
|
||||
|
||||
val PRIVATE_MEDIA_RECEIPTS = PeerCapabilities(1L shl 9)
|
||||
|
||||
/** Reserved by iOS; decode it but do not advertise or act on it. */
|
||||
val NON_DESTRUCTIVE_NOISE_REPLACEMENT = PeerCapabilities(1L shl 10)
|
||||
|
||||
/** Capabilities implemented by this Android build. */
|
||||
val LOCAL_SUPPORTED = PRIVATE_MEDIA
|
||||
|
||||
|
||||
@ -10,37 +10,63 @@ import java.util.concurrent.atomic.AtomicReference
|
||||
* Centralized OkHttp provider to ensure all network traffic honors Tor settings.
|
||||
*/
|
||||
object OkHttpProvider {
|
||||
private val httpClientRef = AtomicReference<OkHttpClient?>(null)
|
||||
private val wsClientRef = AtomicReference<OkHttpClient?>(null)
|
||||
|
||||
fun reset() {
|
||||
httpClientRef.set(null)
|
||||
wsClientRef.set(null)
|
||||
enum class Route {
|
||||
DIRECT,
|
||||
TOR
|
||||
}
|
||||
|
||||
fun httpClient(): OkHttpClient {
|
||||
data class RoutedClient(
|
||||
val client: OkHttpClient,
|
||||
val route: Route
|
||||
)
|
||||
|
||||
private val httpClientRef = AtomicReference<RoutedClient?>(null)
|
||||
private val wsClientRef = AtomicReference<OkHttpClient?>(null)
|
||||
private val clientLock = Any()
|
||||
|
||||
fun reset() {
|
||||
synchronized(clientLock) {
|
||||
httpClientRef.set(null)
|
||||
wsClientRef.set(null)
|
||||
}
|
||||
}
|
||||
|
||||
fun httpClient(): OkHttpClient = routedHttpClient().client
|
||||
|
||||
/**
|
||||
* Returns the client and the route it was actually built with as one snapshot.
|
||||
*
|
||||
* The selected Tor mode can change while an existing client is still cached. Consumers that
|
||||
* key cooldowns by network identity must use this value rather than re-reading the preference.
|
||||
*/
|
||||
fun routedHttpClient(): RoutedClient {
|
||||
httpClientRef.get()?.let { return it }
|
||||
val client = baseBuilderForCurrentProxy()
|
||||
.callTimeout(15, TimeUnit.SECONDS)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
httpClientRef.set(client)
|
||||
return client
|
||||
return synchronized(clientLock) {
|
||||
httpClientRef.get() ?: run {
|
||||
val (builder, route) = baseBuilderForCurrentProxy()
|
||||
val client = builder
|
||||
.callTimeout(15, TimeUnit.SECONDS)
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
RoutedClient(client, route).also(httpClientRef::set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun webSocketClient(): OkHttpClient {
|
||||
wsClientRef.get()?.let { return it }
|
||||
val client = baseBuilderForCurrentProxy()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
wsClientRef.set(client)
|
||||
return client
|
||||
return synchronized(clientLock) {
|
||||
wsClientRef.get() ?: baseBuilderForCurrentProxy().first
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
.also(wsClientRef::set)
|
||||
}
|
||||
}
|
||||
|
||||
private fun baseBuilderForCurrentProxy(): OkHttpClient.Builder {
|
||||
private fun baseBuilderForCurrentProxy(): Pair<OkHttpClient.Builder, Route> {
|
||||
val builder = OkHttpClient.Builder()
|
||||
val torProvider = ArtiTorManager.getInstance()
|
||||
val socks: InetSocketAddress? = torProvider.currentSocksAddress()
|
||||
@ -50,6 +76,6 @@ object OkHttpProvider {
|
||||
val proxy = Proxy(Proxy.Type.SOCKS, socks)
|
||||
builder.proxy(proxy)
|
||||
}
|
||||
return builder
|
||||
return builder to if (socks == null) Route.DIRECT else Route.TOR
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,11 @@ package com.bitchat.android.nostr
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
@ -18,7 +21,8 @@ class GeohashMessageHandler(
|
||||
private val repo: GeohashRepository,
|
||||
private val scope: CoroutineScope,
|
||||
private val dataManager: com.bitchat.android.ui.DataManager,
|
||||
private val addChannelMessage: (String, BitchatMessage) -> Unit
|
||||
private val addChannelMessage: (String, BitchatMessage) -> Unit,
|
||||
private val signatureVerificationDispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
) {
|
||||
companion object { private const val TAG = "GeohashMessageHandler" }
|
||||
|
||||
@ -44,6 +48,13 @@ class GeohashMessageHandler(
|
||||
if (event.kind != NostrKind.EPHEMERAL_EVENT && event.kind != NostrKind.GEOHASH_PRESENCE) return@launch
|
||||
val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1)
|
||||
if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch
|
||||
val hasValidSignature = withContext(signatureVerificationDispatcher) {
|
||||
event.isValidSignature()
|
||||
}
|
||||
if (!hasValidSignature) {
|
||||
Log.w(TAG, "Rejecting geohash event ${event.id.take(8)}... with invalid signature")
|
||||
return@launch
|
||||
}
|
||||
if (dedupe(event.id)) return@launch
|
||||
|
||||
// PoW validation (if enabled) - apply to chat messages primarily
|
||||
|
||||
@ -416,6 +416,11 @@ class LocationNotesManager private constructor() {
|
||||
Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}")
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.isValidSignature()) {
|
||||
Log.w(TAG, "Rejecting note ${event.id.take(8)}... with invalid signature")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for geohash tag
|
||||
val geohashTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }
|
||||
|
||||
@ -282,6 +282,11 @@ class NostrClient private constructor(private val context: Context) {
|
||||
handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit
|
||||
) {
|
||||
try {
|
||||
if (!event.isValidSignature()) {
|
||||
Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... with invalid signature")
|
||||
return
|
||||
}
|
||||
|
||||
// Check Proof of Work validation for incoming geohash events
|
||||
val powSettings = PoWPreferenceManager.getCurrentSettings()
|
||||
if (powSettings.enabled && powSettings.difficulty > 0) {
|
||||
|
||||
@ -132,7 +132,14 @@ class NostrDirectMessageHandler(
|
||||
|
||||
val favoriteControl = FavoriteControlMessage.parse(pm.content)
|
||||
if (favoriteControl != null) {
|
||||
handleFavoriteControl(favoriteControl, conversationID, senderNickname, timestamp, senderPubkey)
|
||||
val admitted = handleFavoriteControl(
|
||||
favoriteControl,
|
||||
conversationID,
|
||||
senderNickname,
|
||||
timestamp,
|
||||
senderPubkey
|
||||
)
|
||||
if (!admitted) return
|
||||
if (!seenStore.hasDelivered(pm.messageID)) {
|
||||
val nostrTransport = NostrTransport.getInstance(application)
|
||||
nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity)
|
||||
@ -157,13 +164,14 @@ class NostrDirectMessageHandler(
|
||||
val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID
|
||||
val suppressUnread = seenStore.hasBeenReadLocally(pm.messageID)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
privateChatManager.handleIncomingPrivateMessage(
|
||||
val admitted = withContext(Dispatchers.Main) {
|
||||
privateChatManager.handleIncomingPrivateMessageDurably(
|
||||
message = message,
|
||||
suppressUnread = suppressUnread,
|
||||
origin = PrivateMessageOrigin.NOSTR
|
||||
)
|
||||
}
|
||||
if (!admitted) return
|
||||
|
||||
if (!seenStore.hasDelivered(pm.messageID)) {
|
||||
val nostrTransport = NostrTransport.getInstance(application)
|
||||
@ -215,19 +223,26 @@ class NostrDirectMessageHandler(
|
||||
senderNostrPubkey = senderPubkey
|
||||
)
|
||||
Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
|
||||
withContext(Dispatchers.Main) {
|
||||
privateChatManager.handleIncomingPrivateMessage(
|
||||
val admitted = withContext(Dispatchers.Main) {
|
||||
privateChatManager.handleIncomingPrivateMessageDurably(
|
||||
message = message,
|
||||
suppressUnread = false,
|
||||
origin = PrivateMessageOrigin.NOSTR
|
||||
)
|
||||
}
|
||||
if (!admitted) {
|
||||
com.bitchat.android.features.file.FileUtils.deleteStoredMediaPaths(
|
||||
application,
|
||||
listOf(savedPath)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Failed to decode Nostr file transfer from $conversationID")
|
||||
}
|
||||
}
|
||||
NoisePayloadType.VERIFY_CHALLENGE,
|
||||
NoisePayloadType.VERIFY_RESPONSE,
|
||||
NoisePayloadType.VOICE_FRAME,
|
||||
NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation.
|
||||
}
|
||||
}
|
||||
@ -238,15 +253,15 @@ class NostrDirectMessageHandler(
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
senderPubkey: String
|
||||
) {
|
||||
try {
|
||||
): Boolean {
|
||||
return try {
|
||||
val senderNpub = control.npub ?: ContactIdentityResolver.npubFromHex(senderPubkey)
|
||||
val noiseKey = senderNpub?.let { FavoritesPersistenceService.shared.findNoiseKey(it) }
|
||||
?: FavoritesPersistenceService.shared.findNoiseKey(senderPubkey)
|
||||
|
||||
if (noiseKey == null) {
|
||||
Log.w(TAG, "Favorite notification from Nostr sender without known Noise key: ${senderPubkey.take(16)}...")
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, control.isFavorite)
|
||||
@ -278,7 +293,7 @@ class NostrDirectMessageHandler(
|
||||
)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
privateChatManager.handleIncomingPrivateMessage(
|
||||
privateChatManager.handleIncomingPrivateMessageDurably(
|
||||
message = systemMessage,
|
||||
suppressUnread = true,
|
||||
origin = PrivateMessageOrigin.NOSTR
|
||||
@ -286,6 +301,7 @@ class NostrDirectMessageHandler(
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to handle Nostr favorite notification: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -679,6 +679,24 @@ class NostrRelayManager private constructor() {
|
||||
Log.e(TAG, "Failed to clear subscriptions: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all subscription tracking, deduplication cache, message queue, and connections for panic mode.
|
||||
*/
|
||||
fun clearAllOnPanic() {
|
||||
try {
|
||||
val wasConnected = desiredConnected.get()
|
||||
clearAllSubscriptions()
|
||||
clearDeduplicationCache()
|
||||
disconnect()
|
||||
if (wasConnected) {
|
||||
desiredConnected.set(true)
|
||||
}
|
||||
Log.w(TAG, "🚨 Cleared NostrRelayManager subscriptions, cache, and connections for panic mode")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear NostrRelayManager on panic: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed status for all relays
|
||||
|
||||
@ -85,9 +85,7 @@ class OnboardingCoordinator(
|
||||
val missingRequired = permissionManager.getMissingPermissions()
|
||||
|
||||
// Optional permissions (ask, but do not block if denied)
|
||||
val optionalToRequest = permissionManager
|
||||
.getOptionalPermissions()
|
||||
.filter { !permissionManager.isPermissionGranted(it) }
|
||||
val optionalToRequest = permissionManager.getUnrequestedOptionalPermissions()
|
||||
|
||||
val missingPermissions = (missingRequired + optionalToRequest).distinct()
|
||||
|
||||
@ -101,6 +99,7 @@ class OnboardingCoordinator(
|
||||
}
|
||||
|
||||
Log.d(TAG, "Requesting ${missingPermissions.size} permissions")
|
||||
permissionManager.markOptionalPermissionsRequested(optionalToRequest)
|
||||
permissionLauncher?.launch(missingPermissions.toTypedArray())
|
||||
}
|
||||
|
||||
@ -115,7 +114,10 @@ class OnboardingCoordinator(
|
||||
|
||||
val allGranted = permissions.values.all { it }
|
||||
val criticalPermissions = getCriticalPermissions()
|
||||
val criticalGranted = criticalPermissions.all { permissions[it] == true }
|
||||
// The launcher result only contains permissions requested in this round. Returning
|
||||
// users may be asked for POST_NOTIFICATIONS alone, so re-check required permissions
|
||||
// against package state instead of treating absent result-map entries as denials.
|
||||
val criticalGranted = criticalPermissions.all(permissionManager::isPermissionGranted)
|
||||
|
||||
when {
|
||||
criticalGranted -> {
|
||||
|
||||
@ -19,6 +19,8 @@ class PermissionManager(private val context: Context) {
|
||||
private const val TAG = "PermissionManager"
|
||||
private const val PREFS_NAME = "bitchat_permissions"
|
||||
private const val KEY_FIRST_TIME_COMPLETE = "first_time_onboarding_complete"
|
||||
private const val KEY_OPTIONAL_PERMISSION_REQUESTED_PREFIX =
|
||||
"optional_permission_requested_"
|
||||
}
|
||||
|
||||
private val sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
@ -149,6 +151,32 @@ class PermissionManager(private val context: Context) {
|
||||
return optional
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional permissions are prompted once. A denial must not trap returning users in
|
||||
* onboarding, while users upgrading to a notification-permission Android version
|
||||
* should still receive one contextual request.
|
||||
*/
|
||||
fun getUnrequestedOptionalPermissions(): List<String> {
|
||||
return getOptionalPermissions().filter { permission ->
|
||||
!isPermissionGranted(permission) &&
|
||||
!sharedPrefs.getBoolean(optionalPermissionRequestKey(permission), false)
|
||||
}
|
||||
}
|
||||
|
||||
fun markOptionalPermissionsRequested(permissions: Collection<String>) {
|
||||
if (permissions.isEmpty()) return
|
||||
|
||||
sharedPrefs.edit().apply {
|
||||
permissions.forEach { permission ->
|
||||
putBoolean(optionalPermissionRequestKey(permission), true)
|
||||
}
|
||||
}.apply()
|
||||
}
|
||||
|
||||
private fun optionalPermissionRequestKey(permission: String): String {
|
||||
return KEY_OPTIONAL_PERMISSION_REQUESTED_PREFIX + permission
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific permission is granted
|
||||
*/
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.bitchat.android.protocol
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
@ -17,7 +18,8 @@ enum class MessageType(val value: UByte) {
|
||||
NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message
|
||||
FRAGMENT(0x20u), // Fragmentation for large packets
|
||||
REQUEST_SYNC(0x21u), // GCS-based sync request
|
||||
FILE_TRANSFER(0x22u); // New: File transfer packet (BLE voice notes, etc.)
|
||||
FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.)
|
||||
VOICE_FRAME(0x29u); // Ephemeral live push-to-talk frame; never added to gossip sync
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: UByte): MessageType? {
|
||||
@ -33,6 +35,27 @@ object SpecialRecipients {
|
||||
val BROADCAST = ByteArray(8) { 0xFF.toByte() } // All 0xFF = broadcast
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload as it arrived on the wire, set by [BinaryProtocol.decode].
|
||||
*
|
||||
* Signatures cover a re-encoding of the packet, and verification re-encodes too.
|
||||
* DEFLATE output is not canonical and clients use different encoders
|
||||
* (java.util.zip.Deflater here, Apple's compression_encode_buffer on iOS), so
|
||||
* re-compressing can change the preimage and reject a valid packet. Reusing these
|
||||
* bytes also stops a relay, which re-encodes on TTL decrement, from substituting
|
||||
* its own encoding.
|
||||
*
|
||||
* [forPayload] ties the bytes to the payload they decode to: replace the payload
|
||||
* and the encoder compresses instead.
|
||||
*
|
||||
* Not a data class: generated equals/hashCode over ByteArray compares references.
|
||||
*/
|
||||
class WirePayload(
|
||||
val bytes: ByteArray,
|
||||
val compressed: Boolean,
|
||||
val forPayload: ByteArray
|
||||
)
|
||||
|
||||
/**
|
||||
* Binary packet format - 100% backward compatible with iOS version
|
||||
*
|
||||
@ -60,7 +83,10 @@ data class BitchatPacket(
|
||||
val payload: ByteArray,
|
||||
var signature: ByteArray? = null, // Changed from val to var for packet signing
|
||||
var ttl: UByte,
|
||||
var route: List<ByteArray>? = null // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient
|
||||
var route: List<ByteArray>? = null, // Optional source route: ordered list of peerIDs (8 bytes each), not including sender and final recipient
|
||||
// Set by BinaryProtocol.decode. Not part of packet identity, so it stays out of
|
||||
// the parcel, equals and hashCode. Losing it only costs a re-compression.
|
||||
@IgnoredOnParcel val wirePayload: WirePayload? = null
|
||||
) : Parcelable {
|
||||
|
||||
constructor(
|
||||
@ -99,7 +125,8 @@ data class BitchatPacket(
|
||||
payload = payload,
|
||||
signature = null, // Remove signature for signing
|
||||
route = route,
|
||||
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||
ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS, // Use fixed TTL=0 for signing to ensure relay compatibility
|
||||
wirePayload = wirePayload // preimage must use the originator's bytes
|
||||
)
|
||||
return BinaryProtocol.encode(unsignedPacket)
|
||||
}
|
||||
@ -213,7 +240,17 @@ object BinaryProtocol {
|
||||
var originalPayloadSize: Int? = null
|
||||
var isCompressed = false
|
||||
|
||||
if (CompressionUtil.shouldCompress(payload)) {
|
||||
// Re-encode of a decoded packet: reuse the originator's bytes (see WirePayload).
|
||||
val wire = packet.wirePayload
|
||||
if (wire != null && wire.forPayload.contentEquals(packet.payload)) {
|
||||
if (wire.compressed) {
|
||||
payload = wire.bytes
|
||||
originalPayloadSize = packet.payload.size
|
||||
isCompressed = true
|
||||
}
|
||||
// Uncompressed on the wire: keep it that way. shouldCompress agrees
|
||||
// across clients, but the "only if smaller" check need not.
|
||||
} else if (CompressionUtil.shouldCompress(payload)) {
|
||||
CompressionUtil.compress(payload)?.let { compressedPayload ->
|
||||
originalPayloadSize = payload.size
|
||||
payload = compressedPayload
|
||||
@ -449,6 +486,8 @@ object BinaryProtocol {
|
||||
} else null
|
||||
|
||||
// Payload
|
||||
// Kept so the packet can be re-encoded byte-identically (see WirePayload).
|
||||
var receivedCompressed: ByteArray? = null
|
||||
val payload = if (isCompressed) {
|
||||
val lengthFieldBytes = if (version >= 2u.toUByte()) 4 else 2
|
||||
if (payloadLength.toInt() < lengthFieldBytes) return null
|
||||
@ -487,6 +526,8 @@ object BinaryProtocol {
|
||||
val expandedPayload = CompressionUtil.withDecompressionResources(resourceBytes) {
|
||||
val compressedPayload = ByteArray(compressedSize)
|
||||
buffer.get(compressedPayload)
|
||||
// Captured here so the allocation stays inside the reservation.
|
||||
receivedCompressed = compressedPayload
|
||||
decompress(compressedPayload, originalSize)
|
||||
} ?: return null
|
||||
if (expandedPayload.size != originalSize) {
|
||||
@ -519,7 +560,12 @@ object BinaryProtocol {
|
||||
payload = payload,
|
||||
signature = signature,
|
||||
ttl = ttl,
|
||||
route = route
|
||||
route = route,
|
||||
wirePayload = WirePayload(
|
||||
bytes = receivedCompressed ?: payload,
|
||||
compressed = receivedCompressed != null,
|
||||
forPayload = payload
|
||||
)
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
|
||||
@ -64,6 +64,12 @@ object AppShutdownCoordinator {
|
||||
val torStop = async {
|
||||
try { torProvider.applyMode(app, TorMode.OFF) } catch (_: Exception) { }
|
||||
}
|
||||
val conversationFlush = async {
|
||||
try {
|
||||
com.bitchat.android.services.AppStateStore
|
||||
.awaitConversationPersistence()
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
// Clear AppState in-memory store
|
||||
try { com.bitchat.android.services.AppStateStore.clear() } catch (_: Exception) { }
|
||||
@ -75,6 +81,7 @@ object AppShutdownCoordinator {
|
||||
// Wait up to 5 seconds for shutdown tasks
|
||||
withTimeoutOrNull(5000) {
|
||||
try { torStop.await() } catch (_: Exception) { }
|
||||
try { conversationFlush.await() } catch (_: Exception) { }
|
||||
delay(100)
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
package com.bitchat.android.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.RemoteInput
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.services.AppStateStore
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.services.MessageRouter
|
||||
import com.bitchat.android.ui.NotificationManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
/** Handles privacy-scoped direct reply and mark-read actions from DM notifications. */
|
||||
class ConversationNotificationReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val conversationID = intent.getStringExtra(NotificationManager.EXTRA_PEER_ID)
|
||||
?.let(ContactDirectory::canonicalConversationId)
|
||||
?: return
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
|
||||
try {
|
||||
var acknowledged = false
|
||||
when (intent.action) {
|
||||
NotificationManager.ACTION_MARK_CONVERSATION_READ -> {
|
||||
acknowledged =
|
||||
AppStateStore.setPrivateConversationRead(conversationID, true)
|
||||
}
|
||||
|
||||
NotificationManager.ACTION_REPLY_TO_CONVERSATION -> {
|
||||
val reply = RemoteInput.getResultsFromIntent(intent)
|
||||
?.getCharSequence(NotificationManager.KEY_TEXT_REPLY)
|
||||
?.toString()
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?: return@launch
|
||||
// A notification can outlive the process/service that posted it. Promote
|
||||
// the mesh runtime before dispatch so Android keeps the transport alive
|
||||
// after this short-lived receiver finishes.
|
||||
MeshForegroundService.start(context.applicationContext)
|
||||
val mesh = MeshServiceHolder.getUnifiedOrCreate(
|
||||
context.applicationContext
|
||||
)
|
||||
val message = BitchatMessage(
|
||||
id = UUID.randomUUID().toString().uppercase(),
|
||||
sender = mesh.myPeerID,
|
||||
content = reply,
|
||||
timestamp = Date(),
|
||||
isPrivate = true,
|
||||
recipientNickname = intent.getStringExtra(
|
||||
NotificationManager.EXTRA_SENDER_NICKNAME
|
||||
),
|
||||
senderPeerID = mesh.myPeerID,
|
||||
deliveryStatus = DeliveryStatus.Sending
|
||||
)
|
||||
val persisted = AppStateStore.addPrivateMessageDurably(
|
||||
peerID = conversationID,
|
||||
msg = message,
|
||||
forceRead = true
|
||||
)
|
||||
if (persisted) {
|
||||
MessageRouter.getInstance(context.applicationContext, mesh)
|
||||
.sendPrivate(
|
||||
content = reply,
|
||||
toPeerID = conversationID,
|
||||
recipientNickname = message.recipientNickname.orEmpty(),
|
||||
messageID = message.id
|
||||
)
|
||||
acknowledged =
|
||||
AppStateStore.setPrivateConversationRead(conversationID, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (acknowledged) {
|
||||
NotificationManager.acknowledgeConversation(context, conversationID)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,8 @@ import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.bitchat.android.MainActivity
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
@ -33,7 +35,6 @@ class MeshForegroundService : Service() {
|
||||
const val ACTION_STOP = "com.bitchat.android.service.STOP"
|
||||
const val ACTION_QUIT = "com.bitchat.android.service.QUIT"
|
||||
const val ACTION_UPDATE_NOTIFICATION = "com.bitchat.android.service.UPDATE_NOTIFICATION"
|
||||
const val ACTION_NOTIFICATION_PERMISSION_GRANTED = "com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED"
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_START }
|
||||
@ -59,22 +60,6 @@ class MeshForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to be invoked right after POST_NOTIFICATIONS is granted to try
|
||||
* promoting/starting the foreground service immediately without polling.
|
||||
*/
|
||||
fun onNotificationPermissionGranted(context: Context) {
|
||||
// If background is enabled and permission now granted, start/promo service
|
||||
if (!shouldStartAsForeground(context)) return
|
||||
|
||||
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_UPDATE_NOTIFICATION }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, MeshForegroundService::class.java).apply { action = ACTION_STOP }
|
||||
context.startService(intent)
|
||||
@ -82,8 +67,7 @@ class MeshForegroundService : Service() {
|
||||
|
||||
private fun shouldStartAsForeground(context: Context): Boolean {
|
||||
return MeshServicePreferences.isBackgroundEnabled(true) &&
|
||||
hasBluetoothPermissionsStatic(context) &&
|
||||
hasNotificationPermissionStatic(context)
|
||||
hasBluetoothPermissionsStatic(context)
|
||||
}
|
||||
|
||||
private fun hasBluetoothPermissionsStatic(ctx: Context): Boolean {
|
||||
@ -98,14 +82,10 @@ class MeshForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasNotificationPermissionStatic(ctx: Context): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= 33) {
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(ctx, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
} else true
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
private lateinit var peerAvailabilityNotifier: PeerAvailabilityNotifier
|
||||
private var updateJob: Job? = null
|
||||
private val meshService: BluetoothMeshService?
|
||||
get() = MeshServiceHolder.meshService
|
||||
@ -121,6 +101,16 @@ class MeshForegroundService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
peerAvailabilityNotifier = PeerAvailabilityNotifier(
|
||||
context = applicationContext,
|
||||
scope = scope,
|
||||
isAppCurrentlyInBackground = {
|
||||
!ProcessLifecycleOwner.get()
|
||||
.lifecycle
|
||||
.currentState
|
||||
.isAtLeast(Lifecycle.State.STARTED)
|
||||
}
|
||||
)
|
||||
createChannel()
|
||||
|
||||
// Ensure mesh service exists in holder (create if needed)
|
||||
@ -139,7 +129,14 @@ class MeshForegroundService : Service() {
|
||||
com.bitchat.android.services.AppStateStore.peers
|
||||
.map { peers -> peers.distinct().size }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
.collect { peerCount ->
|
||||
peerAvailabilityNotifier.onPeerCountChanged(
|
||||
peerCount = peerCount,
|
||||
isAppInBackground = !ProcessLifecycleOwner.get()
|
||||
.lifecycle
|
||||
.currentState
|
||||
.isAtLeast(Lifecycle.State.STARTED)
|
||||
)
|
||||
if (isInForeground) updateNotification(force = false)
|
||||
}
|
||||
}
|
||||
@ -156,11 +153,13 @@ class MeshForegroundService : Service() {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
// Stop FGS and mesh cleanly
|
||||
updateJob?.cancel()
|
||||
updateJob = null
|
||||
try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.stopOutboxScheduler() } catch (_: Exception) { }
|
||||
try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { }
|
||||
try { MeshServiceHolder.clear() } catch (_: Exception) { }
|
||||
try { stopForeground(true) } catch (_: Exception) { }
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
clearMeshNotifications()
|
||||
isInForeground = false
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
@ -170,7 +169,7 @@ class MeshForegroundService : Service() {
|
||||
updateJob?.cancel()
|
||||
updateJob = null
|
||||
try { stopForeground(true) } catch (_: Exception) { }
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
clearMeshNotifications()
|
||||
isInForeground = false
|
||||
// Fully stop all background activity, stop Tor (without changing setting), then kill the app
|
||||
AppShutdownCoordinator.requestFullShutdownAndKill(
|
||||
@ -203,7 +202,9 @@ class MeshForegroundService : Service() {
|
||||
// Ensure mesh is running (only after permissions are granted)
|
||||
ensureMeshStarted()
|
||||
|
||||
// Promote exactly once when eligible, otherwise stay background (or stop)
|
||||
// Promote exactly once when eligible, otherwise stay background (or stop).
|
||||
// POST_NOTIFICATIONS is intentionally not an eligibility requirement: Android 13+
|
||||
// still allows foreground services and exposes them in the system task manager.
|
||||
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
|
||||
val count = getUnifiedActivePeerCount()
|
||||
val notification = buildNotification(count)
|
||||
@ -234,30 +235,35 @@ class MeshForegroundService : Service() {
|
||||
|
||||
private fun updateNotification(force: Boolean) {
|
||||
if (isShuttingDown) {
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
clearMeshNotifications()
|
||||
return
|
||||
}
|
||||
val count = getUnifiedActivePeerCount()
|
||||
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
|
||||
if (lastNotifiedPeerCount != count) {
|
||||
notificationManager.notify(NOTIFICATION_ID, buildNotification(count))
|
||||
startForegroundCompat(buildNotification(count))
|
||||
lastNotifiedPeerCount = count
|
||||
}
|
||||
} else if (force) {
|
||||
// If disabled and forced, make sure to remove any prior foreground state
|
||||
try { stopForeground(false) } catch (_: Exception) { }
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
clearMeshNotifications()
|
||||
isInForeground = false
|
||||
lastNotifiedPeerCount = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearMeshNotifications() {
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
peerAvailabilityNotifier.clear()
|
||||
}
|
||||
|
||||
private fun hasAllRequiredPermissions(): Boolean {
|
||||
// For starting FGS with connectedDevice|dataSync, we need:
|
||||
// - Foreground service permissions (declared in manifest)
|
||||
// - One of the device-related permissions (we request BL perms at runtime)
|
||||
// - On Android 13+, POST_NOTIFICATIONS to actually show notification
|
||||
return hasBluetoothPermissions() && hasNotificationPermission()
|
||||
// POST_NOTIFICATIONS controls notification-drawer visibility, not FGS eligibility.
|
||||
return hasBluetoothPermissions()
|
||||
}
|
||||
|
||||
private fun getUnifiedActivePeerCount(): Int {
|
||||
@ -281,12 +287,6 @@ class MeshForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasNotificationPermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= 33) {
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
} else true
|
||||
}
|
||||
|
||||
private fun buildNotification(activePeers: Int): Notification {
|
||||
val openIntent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
|
||||
@ -136,6 +136,7 @@ object MeshServiceHolder {
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
android.util.Log.d(TAG, "Clearing BluetoothMeshService from holder")
|
||||
try { sharedGossipSyncManager?.clear() } catch (_: Exception) { }
|
||||
try { sharedGossipSyncManager?.stop() } catch (_: Exception) { }
|
||||
sharedGossipSyncManager = null
|
||||
activeGossipOwners.clear()
|
||||
|
||||
@ -0,0 +1,280 @@
|
||||
package com.bitchat.android.service
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.edit
|
||||
import com.bitchat.android.MainActivity
|
||||
import com.bitchat.android.R
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal enum class PeerAvailabilityAction {
|
||||
NONE,
|
||||
SHOW,
|
||||
CLEAR
|
||||
}
|
||||
|
||||
internal interface PeerAvailabilityAlertHistory {
|
||||
var lastAlertAtMillis: Long?
|
||||
}
|
||||
|
||||
internal class SharedPreferencesPeerAvailabilityAlertHistory(
|
||||
context: Context
|
||||
) : PeerAvailabilityAlertHistory {
|
||||
companion object {
|
||||
internal const val PREFERENCES_NAME = "peer_availability_notifications"
|
||||
private const val KEY_LAST_ALERT_AT_MILLIS = "last_alert_at_millis"
|
||||
}
|
||||
|
||||
private val preferences =
|
||||
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
override var lastAlertAtMillis: Long?
|
||||
get() = if (preferences.contains(KEY_LAST_ALERT_AT_MILLIS)) {
|
||||
preferences.getLong(KEY_LAST_ALERT_AT_MILLIS, 0L)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
set(value) {
|
||||
preferences.edit {
|
||||
if (value == null) {
|
||||
remove(KEY_LAST_ALERT_AT_MILLIS)
|
||||
} else {
|
||||
putLong(KEY_LAST_ALERT_AT_MILLIS, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks mesh availability epochs with two anti-flapping gates:
|
||||
* - no more than one alert per persisted cooldown window;
|
||||
* - after an alert, the mesh must remain empty before another epoch can re-arm.
|
||||
*/
|
||||
internal class PeerAvailabilityTracker(
|
||||
private val alertHistory: PeerAvailabilityAlertHistory,
|
||||
private val nowMillis: () -> Long = System::currentTimeMillis,
|
||||
private val alertCooldownMs: Long = ALERT_COOLDOWN_MS,
|
||||
private val emptyRearmDelayMs: Long = EMPTY_REARM_DELAY_MS
|
||||
) {
|
||||
companion object {
|
||||
internal const val ALERT_COOLDOWN_MS = 5 * 60_000L
|
||||
internal const val EMPTY_REARM_DELAY_MS = 30_000L
|
||||
}
|
||||
|
||||
private var previousPeerCount = 0
|
||||
private var isArmed = true
|
||||
private var emptySinceMillis: Long? = null
|
||||
|
||||
init {
|
||||
require(alertCooldownMs >= 0) { "alertCooldownMs must not be negative" }
|
||||
require(emptyRearmDelayMs >= 0) { "emptyRearmDelayMs must not be negative" }
|
||||
}
|
||||
|
||||
fun update(peerCount: Int, isAppInBackground: Boolean): PeerAvailabilityAction {
|
||||
require(peerCount >= 0) { "peerCount must not be negative" }
|
||||
|
||||
val now = nowMillis()
|
||||
if (peerCount == 0) {
|
||||
if (previousPeerCount > 0 || emptySinceMillis == null) {
|
||||
emptySinceMillis = now
|
||||
}
|
||||
previousPeerCount = 0
|
||||
return PeerAvailabilityAction.CLEAR
|
||||
}
|
||||
|
||||
val transitionedFromEmpty = previousPeerCount == 0
|
||||
previousPeerCount = peerCount
|
||||
if (!transitionedFromEmpty) return PeerAvailabilityAction.NONE
|
||||
|
||||
if (!isArmed) {
|
||||
val emptySince = emptySinceMillis
|
||||
val remainedEmptyLongEnough =
|
||||
emptySince != null && now - emptySince >= emptyRearmDelayMs
|
||||
if (!remainedEmptyLongEnough) {
|
||||
emptySinceMillis = null
|
||||
return PeerAvailabilityAction.NONE
|
||||
}
|
||||
isArmed = true
|
||||
}
|
||||
emptySinceMillis = null
|
||||
|
||||
val lastAlertAt = alertHistory.lastAlertAtMillis
|
||||
val cooldownElapsed =
|
||||
lastAlertAt == null || now - lastAlertAt >= alertCooldownMs
|
||||
return if (isAppInBackground && cooldownElapsed) {
|
||||
PeerAvailabilityAction.SHOW
|
||||
} else {
|
||||
PeerAvailabilityAction.NONE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called only after NotificationManager accepts the post. Failed or disabled posts
|
||||
* do not consume the cooldown or require the mesh to re-arm.
|
||||
*/
|
||||
fun markAlertShown() {
|
||||
alertHistory.lastAlertAtMillis = nowMillis()
|
||||
isArmed = false
|
||||
}
|
||||
}
|
||||
|
||||
internal interface PeerAvailabilityTextProvider {
|
||||
fun title(): String
|
||||
fun body(peerCount: Int): String
|
||||
}
|
||||
|
||||
private class AndroidPeerAvailabilityTextProvider(
|
||||
private val context: Context
|
||||
) : PeerAvailabilityTextProvider {
|
||||
override fun title(): String {
|
||||
return context.getString(R.string.notification_active_peers_title)
|
||||
}
|
||||
|
||||
override fun body(peerCount: Int): String {
|
||||
return if (peerCount == 1) {
|
||||
context.getString(R.string.notification_active_peers_one)
|
||||
} else {
|
||||
context.getString(R.string.notification_active_peers_many, peerCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the user-visible "bitchatters nearby" notification independently of the UI delegate.
|
||||
* The first eligible peer starts a fixed aggregation window; arrivals within that window update
|
||||
* the count without postponing the alert indefinitely.
|
||||
*/
|
||||
internal class PeerAvailabilityNotifier(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val notificationManager: NotificationManagerCompat =
|
||||
NotificationManagerCompat.from(context),
|
||||
private val tracker: PeerAvailabilityTracker = PeerAvailabilityTracker(
|
||||
SharedPreferencesPeerAvailabilityAlertHistory(context)
|
||||
),
|
||||
private val textProvider: PeerAvailabilityTextProvider =
|
||||
AndroidPeerAvailabilityTextProvider(context),
|
||||
private val isAppCurrentlyInBackground: () -> Boolean,
|
||||
private val aggregationWindowMs: Long = AGGREGATION_WINDOW_MS,
|
||||
private val canPostNotifications: () -> Boolean = {
|
||||
notificationManager.areNotificationsEnabled() &&
|
||||
(
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
) {
|
||||
companion object {
|
||||
internal const val CHANNEL_ID = "bitchat_peer_availability_notifications"
|
||||
internal const val NOTIFICATION_ID = 997
|
||||
internal const val AGGREGATION_WINDOW_MS = 10_000L
|
||||
private const val TAG = "PeerAvailability"
|
||||
}
|
||||
|
||||
private var latestPeerCount = 0
|
||||
private var pendingNotificationJob: Job? = null
|
||||
|
||||
init {
|
||||
require(aggregationWindowMs >= 0) { "aggregationWindowMs must not be negative" }
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
fun onPeerCountChanged(peerCount: Int, isAppInBackground: Boolean) {
|
||||
latestPeerCount = peerCount
|
||||
when (tracker.update(peerCount, isAppInBackground)) {
|
||||
PeerAvailabilityAction.NONE -> Unit
|
||||
PeerAvailabilityAction.CLEAR -> clear()
|
||||
PeerAvailabilityAction.SHOW -> scheduleNotification()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
pendingNotificationJob?.cancel()
|
||||
pendingNotificationJob = null
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private fun scheduleNotification() {
|
||||
if (pendingNotificationJob?.isActive == true) return
|
||||
|
||||
pendingNotificationJob = scope.launch {
|
||||
delay(aggregationWindowMs)
|
||||
pendingNotificationJob = null
|
||||
|
||||
val peerCount = latestPeerCount
|
||||
if (peerCount > 0 && isAppCurrentlyInBackground()) {
|
||||
showNotification(peerCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
textProvider.title(),
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
enableVibration(true)
|
||||
setShowBadge(false)
|
||||
}
|
||||
val systemManager =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
systemManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun showNotification(peerCount: Int) {
|
||||
if (!canPostNotifications()) {
|
||||
Log.i(TAG, "Skipping peer availability notification because notifications are disabled")
|
||||
return
|
||||
}
|
||||
|
||||
val openAppIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
NOTIFICATION_ID,
|
||||
openAppIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(textProvider.title())
|
||||
.setContentText(textProvider.body(peerCount))
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||
.setShowWhen(true)
|
||||
.setWhen(System.currentTimeMillis())
|
||||
.build()
|
||||
|
||||
try {
|
||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||
tracker.markAlertShown()
|
||||
Log.i(TAG, "Posted peer availability notification for $peerCount peer(s)")
|
||||
} catch (error: SecurityException) {
|
||||
Log.w(TAG, "Notification permission changed before peer alert was posted", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.content.Context
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@ -13,8 +14,10 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
object AppStateStore {
|
||||
// Global de-dup set by message id to avoid duplicate keys in Compose lists
|
||||
private val seenMessageIds = mutableSetOf<String>()
|
||||
private val reservedPrivateMessageIds = mutableSetOf<String>()
|
||||
private val seenPublicMessageKeys = mutableSetOf<String>()
|
||||
private val peerIdsByTransport = mutableMapOf<String, Set<String>>()
|
||||
private var privateWritesSinceGlobalPrune = 0
|
||||
// Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set.
|
||||
private val directPeerIdsByTransport = mutableMapOf<String, Set<String>>()
|
||||
private val _directPeers = MutableStateFlow<Set<String>>(emptySet())
|
||||
@ -30,6 +33,20 @@ object AppStateStore {
|
||||
// Private messages by peerID
|
||||
private val _privateMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||
val privateMessages: StateFlow<Map<String, List<BitchatMessage>>> = _privateMessages.asStateFlow()
|
||||
private val _readPrivateMessageIDs = MutableStateFlow<Set<String>>(emptySet())
|
||||
val readPrivateMessageIDs: StateFlow<Set<String>> = _readPrivateMessageIDs.asStateFlow()
|
||||
private val _unreadPrivateMessageCounts = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||
val unreadPrivateMessageCounts: StateFlow<Map<String, Int>> =
|
||||
_unreadPrivateMessageCounts.asStateFlow()
|
||||
private val _privateConversationDisplayNames =
|
||||
MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
val privateConversationDisplayNames: StateFlow<Map<String, String>> =
|
||||
_privateConversationDisplayNames.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
private var conversationRepository: ConversationRepository? = null
|
||||
private var privateConversationWritesSuspended = false
|
||||
private var privateConversationGeneration = 0L
|
||||
|
||||
private val _nickname = MutableStateFlow("")
|
||||
val nickname: StateFlow<String> = _nickname.asStateFlow()
|
||||
@ -55,6 +72,69 @@ object AppStateStore {
|
||||
_selectedPrivateChatPeer.value = peerID
|
||||
}
|
||||
|
||||
fun initializeConversationPersistence(context: Context) {
|
||||
val repository = ConversationRepository.getInstance(context.applicationContext)
|
||||
conversationRepository = repository
|
||||
repository.initialize(::restorePrivateConversations)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores database state again for a newly created UI, even if Android reused this process
|
||||
* after a controlled shutdown cleared the process-wide state.
|
||||
*/
|
||||
fun reloadConversationPersistence(context: Context) {
|
||||
val repository = ConversationRepository.getInstance(context.applicationContext)
|
||||
conversationRepository = repository
|
||||
repository.reload(::restorePrivateConversations)
|
||||
}
|
||||
|
||||
internal fun setConversationRepositoryForTest(repository: ConversationRepository?) {
|
||||
conversationRepository = repository
|
||||
}
|
||||
|
||||
suspend fun awaitConversationPersistence() {
|
||||
conversationRepository?.awaitPendingWrites()
|
||||
}
|
||||
|
||||
suspend fun loadPrivateConversationHistory(conversationID: String): Boolean {
|
||||
val repository = conversationRepository ?: return false
|
||||
val snapshot = repository.loadConversationAndWait(
|
||||
ContactDirectory.canonicalConversationId(conversationID)
|
||||
) ?: return false
|
||||
restorePrivateConversations(snapshot)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops an opened conversation's full payloads from memory while retaining its summary row.
|
||||
* The complete bounded history remains encrypted in SQLite and is loaded again on demand.
|
||||
*/
|
||||
fun releasePrivateConversationHistory(conversationID: String) {
|
||||
synchronized(this) {
|
||||
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
|
||||
val matching = _privateMessages.value.entries.filter { (id, _) ->
|
||||
ContactDirectory.canonicalConversationId(id)
|
||||
.equals(canonicalID, ignoreCase = true)
|
||||
}
|
||||
val latest = matching
|
||||
.flatMap { it.value }
|
||||
.distinctBy { it.id }
|
||||
.maxWithOrNull(
|
||||
compareBy<BitchatMessage> {
|
||||
PrivateMessageArrivalOrder.sequenceOf(it.id) ?: Long.MIN_VALUE
|
||||
}.thenBy { it.timestamp.time }
|
||||
)
|
||||
?: return
|
||||
val compacted = _privateMessages.value.toMutableMap()
|
||||
matching.forEach { compacted.remove(it.key) }
|
||||
compacted[canonicalID] = listOf(latest)
|
||||
_privateMessages.value = compacted
|
||||
}
|
||||
}
|
||||
|
||||
val conversationStoreState: StateFlow<ConversationStoreState>
|
||||
get() = conversationRepository?.storeState ?: EMPTY_CONVERSATION_STORE_STATE
|
||||
|
||||
fun setTransportPeers(transportId: String, ids: List<String>) {
|
||||
synchronized(this) {
|
||||
peerIdsByTransport[transportId] = ids.toSet()
|
||||
@ -116,19 +196,218 @@ object AppStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
fun addPrivateMessage(peerID: String, msg: BitchatMessage) {
|
||||
/** Replace a live media row by ID, or append it if the row was not admitted yet. */
|
||||
fun upsertPublicMessage(msg: BitchatMessage) {
|
||||
synchronized(this) {
|
||||
if (seenMessageIds.contains(msg.id)) return
|
||||
seenMessageIds.add(msg.id)
|
||||
PrivateMessageArrivalOrder.record(msg.id)
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val map = _privateMessages.value.toMutableMap()
|
||||
val list = (map[conversationID] ?: emptyList()) + msg
|
||||
map[conversationID] = list
|
||||
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
|
||||
val index = _publicMessages.value.indexOfFirst { it.id == msg.id }
|
||||
if (index >= 0) {
|
||||
_publicMessages.value = _publicMessages.value.toMutableList().also { it[index] = msg }
|
||||
} else {
|
||||
seenMessageIds.add(msg.id)
|
||||
seenPublicMessageKeys.add(publicMessageKey(msg))
|
||||
_publicMessages.value = _publicMessages.value + msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removePublicMessage(messageID: String) {
|
||||
synchronized(this) {
|
||||
val existing = _publicMessages.value.firstOrNull { it.id == messageID } ?: return
|
||||
_publicMessages.value = _publicMessages.value.filterNot { it.id == messageID }
|
||||
seenMessageIds.remove(messageID)
|
||||
seenPublicMessageKeys.remove(publicMessageKey(existing))
|
||||
}
|
||||
}
|
||||
|
||||
fun addPrivateMessage(
|
||||
peerID: String,
|
||||
msg: BitchatMessage,
|
||||
forceRead: Boolean = false
|
||||
): Boolean = synchronized(this) {
|
||||
addPrivateMessageLocked(peerID, msg, forceRead, persistAsynchronously = true)
|
||||
}
|
||||
|
||||
/** Replace-or-append used by a live voice row as its partial file becomes final media. */
|
||||
fun upsertPrivateMessage(peerID: String, msg: BitchatMessage, forceRead: Boolean = false) {
|
||||
synchronized(this) {
|
||||
if (privateConversationWritesSuspended) return
|
||||
val canonicalID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val map = _privateMessages.value.toMutableMap()
|
||||
val matchingKey = map.keys.firstOrNull {
|
||||
ContactDirectory.canonicalConversationId(it).equals(canonicalID, ignoreCase = true)
|
||||
} ?: canonicalID
|
||||
val messages = map[matchingKey].orEmpty().toMutableList()
|
||||
val index = messages.indexOfFirst { it.id == msg.id }
|
||||
if (index >= 0) {
|
||||
messages[index] = msg
|
||||
} else {
|
||||
messages += msg
|
||||
seenMessageIds.add(msg.id)
|
||||
}
|
||||
map[matchingKey] = messages
|
||||
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
|
||||
if (forceRead) {
|
||||
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + msg.id
|
||||
}
|
||||
conversationRepository?.upsertMessage(
|
||||
conversationID = canonicalID,
|
||||
aliases = privateConversationAliases(peerID, canonicalID),
|
||||
displayName = ContactDirectory.resolve(canonicalID).displayName,
|
||||
message = msg,
|
||||
isRead = forceRead
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists an incoming private message before it is admitted to UI, unread, haptic, or
|
||||
* notification state. Transport callbacks invoke this from their background worker.
|
||||
*/
|
||||
suspend fun addPrivateMessageDurably(
|
||||
peerID: String,
|
||||
msg: BitchatMessage,
|
||||
forceRead: Boolean = false
|
||||
): Boolean {
|
||||
val persistence = synchronized(this) {
|
||||
if (privateConversationWritesSuspended) return false
|
||||
if (seenMessageIds.contains(msg.id) || !reservedPrivateMessageIds.add(msg.id)) {
|
||||
return false
|
||||
}
|
||||
privateMessagePersistence(peerID, msg, forceRead)
|
||||
}
|
||||
val repository = persistence.repository
|
||||
if (repository == null) {
|
||||
synchronized(this) { reservedPrivateMessageIds.remove(msg.id) }
|
||||
return false
|
||||
}
|
||||
val persisted = repository.upsertMessageAndWait(
|
||||
conversationID = persistence.conversationID,
|
||||
aliases = persistence.aliases,
|
||||
displayName = persistence.displayName,
|
||||
message = msg,
|
||||
isRead = persistence.isRead
|
||||
)
|
||||
return synchronized(this) {
|
||||
reservedPrivateMessageIds.remove(msg.id)
|
||||
if (
|
||||
!persisted ||
|
||||
privateConversationWritesSuspended ||
|
||||
persistence.generation != privateConversationGeneration ||
|
||||
seenMessageIds.contains(msg.id)
|
||||
) {
|
||||
return@synchronized false
|
||||
}
|
||||
addPrivateMessageLocked(
|
||||
peerID = peerID,
|
||||
msg = msg,
|
||||
forceRead = forceRead,
|
||||
persistAsynchronously = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addPrivateMessageLocked(
|
||||
peerID: String,
|
||||
msg: BitchatMessage,
|
||||
forceRead: Boolean,
|
||||
persistAsynchronously: Boolean
|
||||
): Boolean {
|
||||
if (privateConversationWritesSuspended) return false
|
||||
if (seenMessageIds.contains(msg.id)) return false
|
||||
seenMessageIds.add(msg.id)
|
||||
PrivateMessageArrivalOrder.record(msg.id)
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val map = _privateMessages.value.toMutableMap()
|
||||
val list = (map[conversationID] ?: emptyList()) + msg
|
||||
map[conversationID] = list
|
||||
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
|
||||
|
||||
val isRead = forceRead ||
|
||||
msg.sender == "system" ||
|
||||
msg.sender == _nickname.value ||
|
||||
_selectedPrivateChatPeer.value
|
||||
?.let(ContactDirectory::canonicalConversationId)
|
||||
?.equals(conversationID, ignoreCase = true) == true
|
||||
if (isRead) {
|
||||
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + msg.id
|
||||
} else {
|
||||
val counts = _unreadPrivateMessageCounts.value.toMutableMap()
|
||||
counts[conversationID] = (counts[conversationID] ?: 0) + 1
|
||||
_unreadPrivateMessageCounts.value = counts
|
||||
}
|
||||
val aliases = privateConversationAliases(peerID, conversationID)
|
||||
val displayName = ContactDirectory.resolve(conversationID).displayName
|
||||
?: msg.sender.takeUnless {
|
||||
it.isBlank() || it == "system" || it == _nickname.value
|
||||
}
|
||||
displayName
|
||||
?.takeUnless {
|
||||
it.isBlank() || it.equals("Unknown", ignoreCase = true)
|
||||
}
|
||||
?.let { updateConversationDisplayNameLocked(conversationID, it) }
|
||||
if (persistAsynchronously) {
|
||||
conversationRepository?.upsertMessage(
|
||||
conversationID = conversationID,
|
||||
aliases = aliases,
|
||||
displayName = displayName,
|
||||
message = msg,
|
||||
isRead = isRead
|
||||
)
|
||||
}
|
||||
prunePrivateMessagesLocked(conversationID)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun privateMessagePersistence(
|
||||
peerID: String,
|
||||
msg: BitchatMessage,
|
||||
forceRead: Boolean
|
||||
): PendingPrivateMessagePersistence {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val existingMessages = _privateMessages.value[conversationID].orEmpty()
|
||||
val isRead = forceRead ||
|
||||
msg.sender == "system" ||
|
||||
msg.sender == _nickname.value ||
|
||||
_selectedPrivateChatPeer.value
|
||||
?.let(ContactDirectory::canonicalConversationId)
|
||||
?.equals(conversationID, ignoreCase = true) == true
|
||||
val aliases = privateConversationAliases(peerID, conversationID)
|
||||
val displayName = ContactDirectory.resolve(conversationID).displayName
|
||||
?: (existingMessages + msg)
|
||||
.lastOrNull { candidate ->
|
||||
candidate.sender.isNotBlank() &&
|
||||
candidate.sender != "system" &&
|
||||
candidate.sender != _nickname.value
|
||||
}
|
||||
?.sender
|
||||
return PendingPrivateMessagePersistence(
|
||||
repository = conversationRepository,
|
||||
conversationID = conversationID,
|
||||
aliases = aliases,
|
||||
displayName = displayName,
|
||||
isRead = isRead,
|
||||
generation = privateConversationGeneration
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive aliases from the routed conversation, never from the message author.
|
||||
*
|
||||
* For outgoing messages senderPeerID is our own ID, which is shared by every private chat.
|
||||
* Persisting it as an alias would merge otherwise unrelated conversation histories.
|
||||
*/
|
||||
private fun privateConversationAliases(
|
||||
peerID: String,
|
||||
conversationID: String
|
||||
): Set<String> = runCatching {
|
||||
ContactDirectory.aliasesForConversation(peerID) +
|
||||
ContactDirectory.aliasesForConversation(conversationID)
|
||||
}.getOrDefault(setOf(peerID, conversationID))
|
||||
|
||||
fun hasSeenMessage(messageID: String): Boolean = synchronized(this) {
|
||||
messageID in seenMessageIds
|
||||
}
|
||||
|
||||
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
|
||||
null -> 0
|
||||
is DeliveryStatus.Sending -> 1
|
||||
@ -141,6 +420,7 @@ object AppStateStore {
|
||||
|
||||
fun updatePrivateMessageStatus(messageID: String, status: DeliveryStatus) {
|
||||
synchronized(this) {
|
||||
if (privateConversationWritesSuspended) return
|
||||
val map = _privateMessages.value.toMutableMap()
|
||||
var changed = false
|
||||
map.keys.toList().forEach { peer ->
|
||||
@ -149,7 +429,14 @@ object AppStateStore {
|
||||
if (idx >= 0) {
|
||||
val current = list[idx].deliveryStatus
|
||||
// Do not downgrade (e.g., Read -> Delivered)
|
||||
if (statusPriority(status) >= statusPriority(current)) {
|
||||
val mayReplace = when {
|
||||
status is DeliveryStatus.Failed ->
|
||||
current !is DeliveryStatus.Delivered &&
|
||||
current !is DeliveryStatus.Read
|
||||
current is DeliveryStatus.Failed -> true
|
||||
else -> statusPriority(status) >= statusPriority(current)
|
||||
}
|
||||
if (mayReplace) {
|
||||
list[idx] = list[idx].copy(deliveryStatus = status)
|
||||
map[peer] = list
|
||||
changed = true
|
||||
@ -159,13 +446,26 @@ object AppStateStore {
|
||||
if (changed) {
|
||||
_privateMessages.value = map
|
||||
}
|
||||
// Full histories are unloaded after a chat closes, so the message may only exist in
|
||||
// SQLite. Always offer the update to the repository; it safely ignores unknown IDs
|
||||
// and enforces the same monotonic status rules as the in-memory path.
|
||||
conversationRepository?.updateDeliveryStatus(messageID, status)
|
||||
}
|
||||
}
|
||||
|
||||
fun unifyPrivateChatsIntoPeer(targetPeerID: String, keysToMerge: List<String>) {
|
||||
if (keysToMerge.isEmpty()) return
|
||||
synchronized(this) {
|
||||
if (privateConversationWritesSuspended) return
|
||||
val targetConversationID = ContactDirectory.canonicalConversationId(targetPeerID)
|
||||
val persistenceAliases = (keysToMerge + targetPeerID + targetConversationID)
|
||||
.flatMap { key ->
|
||||
runCatching {
|
||||
ContactDirectory.aliasesForConversation(key).toList()
|
||||
}.getOrDefault(listOf(key))
|
||||
}
|
||||
.toSet()
|
||||
conversationRepository?.mergeAliases(targetConversationID, persistenceAliases)
|
||||
val map = _privateMessages.value.toMutableMap()
|
||||
val targetList = (map[targetConversationID] ?: emptyList()).toMutableList()
|
||||
val targetIds = targetList.map { it.id }.toMutableSet()
|
||||
@ -199,20 +499,330 @@ object AppStateStore {
|
||||
} else {
|
||||
map[targetConversationID] = targetList
|
||||
}
|
||||
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(map)
|
||||
_privateMessages.value = map
|
||||
}
|
||||
canonicalizePrivateConversationStateLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun canonicalizePrivateChats() {
|
||||
synchronized(this) {
|
||||
val canonical = ContactDirectory.canonicalizePrivateChats(_privateMessages.value)
|
||||
if (canonical != _privateMessages.value) {
|
||||
_privateMessages.value = canonical
|
||||
canonicalizePrivateConversationStateLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies current peer announcements to retained conversations and persists the latest name
|
||||
* independently of message history. A nickname change must not require another message to
|
||||
* survive process death.
|
||||
*/
|
||||
fun updatePrivateConversationDisplayNames(peerNicknames: Map<String, String>) {
|
||||
if (peerNicknames.isEmpty()) return
|
||||
synchronized(this) {
|
||||
if (privateConversationWritesSuspended || _privateMessages.value.isEmpty()) return
|
||||
canonicalizePrivateConversationStateLocked()
|
||||
val conversationIDs = _privateMessages.value.keys
|
||||
.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
peerNicknames.forEach { (peerID, nickname) ->
|
||||
val usableName = nickname.takeUnless {
|
||||
it.isBlank() || it.equals("Unknown", ignoreCase = true)
|
||||
} ?: return@forEach
|
||||
val canonicalID = ContactDirectory.canonicalConversationId(peerID)
|
||||
if (canonicalID.lowercase() !in conversationIDs) {
|
||||
return@forEach
|
||||
}
|
||||
updateConversationDisplayNameLocked(canonicalID, usableName)
|
||||
val aliases = runCatching {
|
||||
ContactDirectory.aliasesForConversation(peerID) +
|
||||
ContactDirectory.aliasesForConversation(canonicalID)
|
||||
}.getOrDefault(setOf(peerID, canonicalID))
|
||||
conversationRepository?.updateConversationIdentity(
|
||||
conversationID = canonicalID,
|
||||
aliases = aliases,
|
||||
displayName = usableName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markPrivateMessageRead(messageID: String) {
|
||||
synchronized(this) {
|
||||
if (privateConversationWritesSuspended) return
|
||||
if (messageID in _readPrivateMessageIDs.value) return
|
||||
canonicalizePrivateConversationStateLocked()
|
||||
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageID
|
||||
val conversationID = _privateMessages.value.entries
|
||||
.firstOrNull { (_, messages) -> messages.any { it.id == messageID } }
|
||||
?.key
|
||||
if (conversationID != null) {
|
||||
val counts = _unreadPrivateMessageCounts.value.toMutableMap()
|
||||
val remaining = ((counts[conversationID] ?: 0) - 1).coerceAtLeast(0)
|
||||
if (remaining == 0) counts.remove(conversationID) else {
|
||||
counts[conversationID] = remaining
|
||||
}
|
||||
_unreadPrivateMessageCounts.value = counts
|
||||
}
|
||||
conversationRepository?.markRead(messageID)
|
||||
}
|
||||
}
|
||||
|
||||
fun isPrivateMessageRead(messageID: String): Boolean =
|
||||
messageID in _readPrivateMessageIDs.value
|
||||
|
||||
suspend fun setPrivateConversationRead(
|
||||
conversationID: String,
|
||||
isRead: Boolean
|
||||
): Boolean {
|
||||
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
|
||||
val repository = conversationRepository ?: return false
|
||||
val result = repository.setConversationReadAndWait(canonicalID, isRead)
|
||||
if (!result.success) return false
|
||||
synchronized(this) {
|
||||
val messageIDs = _privateMessages.value
|
||||
.filterKeys { key ->
|
||||
ContactDirectory.canonicalConversationId(key)
|
||||
.equals(canonicalID, ignoreCase = true)
|
||||
}
|
||||
.values
|
||||
.flatten()
|
||||
.mapTo(linkedSetOf(), BitchatMessage::id)
|
||||
if (isRead) {
|
||||
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value + messageIDs
|
||||
_unreadPrivateMessageCounts.value =
|
||||
_unreadPrivateMessageCounts.value.filterKeys { key ->
|
||||
!ContactDirectory.canonicalConversationId(key)
|
||||
.equals(canonicalID, ignoreCase = true)
|
||||
}
|
||||
} else {
|
||||
result.affectedMessageID?.let { latestMessageID ->
|
||||
_readPrivateMessageIDs.value =
|
||||
_readPrivateMessageIDs.value - latestMessageID
|
||||
_unreadPrivateMessageCounts.value =
|
||||
_unreadPrivateMessageCounts.value + (canonicalID to 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun deletePrivateConversation(peerOrConversationID: String): Set<String> {
|
||||
synchronized(this) {
|
||||
val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID)
|
||||
val matchingKeys = _privateMessages.value.keys.filterTo(linkedSetOf()) { key ->
|
||||
ContactDirectory.canonicalConversationId(key)
|
||||
.equals(canonicalID, ignoreCase = true)
|
||||
}
|
||||
val aliases = (matchingKeys + peerOrConversationID + canonicalID)
|
||||
.flatMap { key ->
|
||||
runCatching {
|
||||
ContactDirectory.aliasesForConversation(key).toList()
|
||||
}.getOrDefault(listOf(key))
|
||||
}
|
||||
.toSet()
|
||||
val messageIDs = matchingKeys
|
||||
.flatMapTo(linkedSetOf()) { _privateMessages.value[it].orEmpty().map { it.id } }
|
||||
|
||||
// Queue the database deletion while holding the same lock used by addPrivateMessage.
|
||||
// A genuinely new arrival is therefore queued after the delete and starts a fresh chat.
|
||||
conversationRepository?.deleteConversation(canonicalID, aliases)
|
||||
|
||||
val updated = _privateMessages.value.toMutableMap()
|
||||
matchingKeys.forEach(updated::remove)
|
||||
_privateMessages.value = updated
|
||||
removeConversationDisplayNamesLocked(canonicalID)
|
||||
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageIDs
|
||||
_unreadPrivateMessageCounts.value =
|
||||
_unreadPrivateMessageCounts.value - matchingKeys - canonicalID
|
||||
if (
|
||||
_selectedPrivateChatPeer.value
|
||||
?.let(ContactDirectory::canonicalConversationId)
|
||||
?.equals(canonicalID, ignoreCase = true) == true
|
||||
) {
|
||||
_selectedPrivateChatPeer.value = null
|
||||
}
|
||||
return messageIDs
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun deletePrivateConversationAndWait(
|
||||
peerOrConversationID: String
|
||||
): DeletedPrivateConversation? {
|
||||
loadPrivateConversationHistory(peerOrConversationID)
|
||||
val deletion = synchronized(this) {
|
||||
if (privateConversationWritesSuspended) return null
|
||||
buildDeletedConversationLocked(peerOrConversationID)
|
||||
}
|
||||
val repository = conversationRepository ?: return null
|
||||
if (!repository.deleteConversationAndWait(deletion.conversationID, deletion.aliases)) {
|
||||
return null
|
||||
}
|
||||
synchronized(this) {
|
||||
val updated = _privateMessages.value.toMutableMap()
|
||||
updated.keys.toList().forEach { key ->
|
||||
if (
|
||||
ContactDirectory.canonicalConversationId(key)
|
||||
.equals(deletion.conversationID, ignoreCase = true)
|
||||
) {
|
||||
val remaining = updated[key].orEmpty().filterNot {
|
||||
it.id in deletion.messageIDs
|
||||
}
|
||||
if (remaining.isEmpty()) updated.remove(key) else updated[key] = remaining
|
||||
}
|
||||
}
|
||||
_privateMessages.value = updated
|
||||
removeConversationDisplayNamesLocked(deletion.conversationID)
|
||||
_readPrivateMessageIDs.value =
|
||||
_readPrivateMessageIDs.value - deletion.messageIDs
|
||||
canonicalizePrivateConversationStateLocked()
|
||||
val counts = _unreadPrivateMessageCounts.value.toMutableMap()
|
||||
val currentCount = counts[deletion.conversationID] ?: 0
|
||||
val remainingUnread = (currentCount - deletion.unreadMessageCount).coerceAtLeast(0)
|
||||
if (remainingUnread == 0) counts.remove(deletion.conversationID) else {
|
||||
counts[deletion.conversationID] = remainingUnread
|
||||
}
|
||||
_unreadPrivateMessageCounts.value = counts
|
||||
if (
|
||||
_selectedPrivateChatPeer.value
|
||||
?.let(ContactDirectory::canonicalConversationId)
|
||||
?.equals(deletion.conversationID, ignoreCase = true) == true
|
||||
) {
|
||||
_selectedPrivateChatPeer.value = null
|
||||
}
|
||||
}
|
||||
return deletion
|
||||
}
|
||||
|
||||
internal suspend fun restoreDeletedConversation(
|
||||
deletion: DeletedPrivateConversation
|
||||
): Boolean {
|
||||
val repository = conversationRepository ?: return false
|
||||
val restoredDisplayName =
|
||||
ContactDirectory.resolve(deletion.conversationID).displayName
|
||||
?: deletion.displayName
|
||||
if (
|
||||
!repository.restoreConversationAndWait(
|
||||
conversationID = deletion.conversationID,
|
||||
aliases = deletion.aliases,
|
||||
displayName = restoredDisplayName,
|
||||
messages = deletion.messages,
|
||||
readMessageIDs = deletion.readMessageIDs
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
synchronized(this) {
|
||||
seenMessageIds.removeAll(deletion.messageIDs)
|
||||
deletion.messages.forEach { message ->
|
||||
addPrivateMessageLocked(
|
||||
peerID = deletion.conversationID,
|
||||
msg = message,
|
||||
forceRead = message.id in deletion.readMessageIDs,
|
||||
persistAsynchronously = false
|
||||
)
|
||||
}
|
||||
restoredDisplayName?.let {
|
||||
updateConversationDisplayNameLocked(deletion.conversationID, it)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun buildDeletedConversationLocked(
|
||||
peerOrConversationID: String
|
||||
): DeletedPrivateConversation {
|
||||
val canonicalID = ContactDirectory.canonicalConversationId(peerOrConversationID)
|
||||
val matchingKeys = _privateMessages.value.keys.filterTo(linkedSetOf()) { key ->
|
||||
ContactDirectory.canonicalConversationId(key)
|
||||
.equals(canonicalID, ignoreCase = true)
|
||||
}
|
||||
val aliases = (matchingKeys + peerOrConversationID + canonicalID)
|
||||
.flatMap { key ->
|
||||
runCatching {
|
||||
ContactDirectory.aliasesForConversation(key).toList()
|
||||
}.getOrDefault(listOf(key))
|
||||
}
|
||||
.toSet()
|
||||
val messages = matchingKeys
|
||||
.flatMap { _privateMessages.value[it].orEmpty() }
|
||||
.distinctBy(BitchatMessage::id)
|
||||
val messageIDs = messages.mapTo(linkedSetOf(), BitchatMessage::id)
|
||||
val readIDs = _readPrivateMessageIDs.value.intersect(messageIDs)
|
||||
return DeletedPrivateConversation(
|
||||
conversationID = canonicalID,
|
||||
aliases = aliases,
|
||||
displayName = _privateConversationDisplayNames.value.entries
|
||||
.firstOrNull { (key, _) ->
|
||||
ContactDirectory.canonicalConversationId(key)
|
||||
.equals(canonicalID, ignoreCase = true)
|
||||
}
|
||||
?.value
|
||||
?: ContactDirectory.resolve(canonicalID).displayName,
|
||||
messages = messages,
|
||||
readMessageIDs = readIDs,
|
||||
unreadMessageCount = messages.count { message ->
|
||||
message.id !in readIDs &&
|
||||
message.sender != "system" &&
|
||||
message.sender != _nickname.value
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun removePrivateMessage(messageID: String) {
|
||||
synchronized(this) {
|
||||
val updated = _privateMessages.value.toMutableMap()
|
||||
var changed = false
|
||||
updated.keys.toList().forEach { conversationID ->
|
||||
val messages = updated[conversationID].orEmpty()
|
||||
if (messages.any { it.id == messageID }) {
|
||||
val remaining = messages.filterNot { it.id == messageID }
|
||||
if (remaining.isEmpty()) {
|
||||
updated.remove(conversationID)
|
||||
} else {
|
||||
updated[conversationID] = remaining
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (!changed) return
|
||||
conversationRepository?.deleteMessage(messageID)
|
||||
_privateMessages.value = updated
|
||||
val retainedConversationIDs = updated.keys
|
||||
.mapTo(mutableSetOf()) {
|
||||
ContactDirectory.canonicalConversationId(it).lowercase()
|
||||
}
|
||||
_privateConversationDisplayNames.value =
|
||||
_privateConversationDisplayNames.value.filterKeys {
|
||||
ContactDirectory.canonicalConversationId(it).lowercase() in
|
||||
retainedConversationIDs
|
||||
}
|
||||
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value - messageID
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically hides all conversations, rejects in-flight transport deliveries, then waits for
|
||||
* every earlier database write and the panic wipe itself to finish.
|
||||
*/
|
||||
suspend fun panicClearPrivateConversations(): Boolean {
|
||||
val repository = synchronized(this) {
|
||||
privateConversationWritesSuspended = true
|
||||
privateConversationGeneration += 1
|
||||
_privateMessages.value = emptyMap()
|
||||
_readPrivateMessageIDs.value = emptySet()
|
||||
_unreadPrivateMessageCounts.value = emptyMap()
|
||||
_privateConversationDisplayNames.value = emptyMap()
|
||||
_selectedPrivateChatPeer.value = null
|
||||
conversationRepository
|
||||
}
|
||||
return repository?.clearAllAndWait() ?: true
|
||||
}
|
||||
|
||||
fun resumePrivateConversationsAfterPanic() {
|
||||
synchronized(this) {
|
||||
privateConversationWritesSuspended = false
|
||||
}
|
||||
}
|
||||
|
||||
fun addChannelMessage(channel: String, msg: BitchatMessage) {
|
||||
synchronized(this) {
|
||||
if (seenMessageIds.contains(msg.id)) return
|
||||
@ -228,14 +838,20 @@ object AppStateStore {
|
||||
fun clear() {
|
||||
synchronized(this) {
|
||||
seenMessageIds.clear()
|
||||
reservedPrivateMessageIds.clear()
|
||||
privateConversationGeneration += 1
|
||||
seenPublicMessageKeys.clear()
|
||||
PrivateMessageArrivalOrder.clear()
|
||||
privateWritesSinceGlobalPrune = 0
|
||||
peerIdsByTransport.clear()
|
||||
directPeerIdsByTransport.clear()
|
||||
_peers.value = emptyList()
|
||||
_directPeers.value = emptySet()
|
||||
_publicMessages.value = emptyList()
|
||||
_privateMessages.value = emptyMap()
|
||||
_readPrivateMessageIDs.value = emptySet()
|
||||
_unreadPrivateMessageCounts.value = emptyMap()
|
||||
_privateConversationDisplayNames.value = emptyMap()
|
||||
_channelMessages.value = emptyMap()
|
||||
_nickname.value = ""
|
||||
_selectedPrivateChatPeer.value = null
|
||||
@ -252,4 +868,253 @@ object AppStateStore {
|
||||
msg.content
|
||||
).joinToString("\u001F")
|
||||
}
|
||||
|
||||
internal fun restorePrivateConversations(snapshot: PersistedConversationSnapshot) {
|
||||
synchronized(this) {
|
||||
if (privateConversationWritesSuspended) return
|
||||
val liveChats = _privateMessages.value
|
||||
val liveMessageIDs = liveChats.values.flatten().map { it.id }
|
||||
PrivateMessageArrivalOrder.restore(
|
||||
snapshot.arrivalOrder,
|
||||
liveMessageIDs,
|
||||
snapshot.receivedAtByMessageID,
|
||||
snapshot.arrivalSequenceByMessageID
|
||||
)
|
||||
|
||||
val merged = linkedMapOf<String, MutableList<BitchatMessage>>()
|
||||
snapshot.chats.forEach { (conversationID, messages) ->
|
||||
merged.getOrPut(conversationID) { mutableListOf() }.addAll(messages)
|
||||
}
|
||||
liveChats.forEach { (conversationID, messages) ->
|
||||
val target = merged.getOrPut(conversationID) { mutableListOf() }
|
||||
messages
|
||||
.filterNot { it.id in snapshot.deletedMessageIDs }
|
||||
.forEach { live ->
|
||||
val existingIndex = target.indexOfFirst { it.id == live.id }
|
||||
if (existingIndex >= 0) {
|
||||
target[existingIndex] = live
|
||||
} else {
|
||||
target.add(live)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merged.values.flatten().forEach { seenMessageIds.add(it.id) }
|
||||
seenMessageIds.addAll(snapshot.deletedMessageIDs)
|
||||
_readPrivateMessageIDs.value =
|
||||
(snapshot.readMessageIDs + _readPrivateMessageIDs.value) -
|
||||
snapshot.deletedMessageIDs
|
||||
val unreadCounts = _unreadPrivateMessageCounts.value.toMutableMap()
|
||||
snapshot.unreadCounts.forEach { (conversationID, count) ->
|
||||
if (count > 0) unreadCounts[conversationID] = count
|
||||
else unreadCounts.remove(conversationID)
|
||||
}
|
||||
_unreadPrivateMessageCounts.value = unreadCounts
|
||||
_privateConversationDisplayNames.value =
|
||||
snapshot.displayNames + _privateConversationDisplayNames.value
|
||||
_privateMessages.value = ContactDirectory.canonicalizePrivateChats(
|
||||
merged.mapValues { (_, messages) ->
|
||||
PrivateMessageArrivalOrder.order(messages.distinctBy { it.id })
|
||||
}
|
||||
)
|
||||
canonicalizePrivateConversationStateLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateConversationDisplayNameLocked(
|
||||
conversationID: String,
|
||||
displayName: String
|
||||
) {
|
||||
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
|
||||
val updated = _privateConversationDisplayNames.value
|
||||
.filterKeys { key ->
|
||||
!ContactDirectory.canonicalConversationId(key)
|
||||
.equals(canonicalID, ignoreCase = true)
|
||||
}
|
||||
.toMutableMap()
|
||||
updated[canonicalID] = displayName
|
||||
_privateConversationDisplayNames.value = updated
|
||||
}
|
||||
|
||||
private fun removeConversationDisplayNamesLocked(conversationID: String) {
|
||||
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
|
||||
_privateConversationDisplayNames.value =
|
||||
_privateConversationDisplayNames.value.filterKeys { key ->
|
||||
!ContactDirectory.canonicalConversationId(key)
|
||||
.equals(canonicalID, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity mappings can become richer after a Noise handshake or favorite/Nostr update.
|
||||
* Keep every process-wide projection on the same canonical key so unread/read/delete updates
|
||||
* cannot leave a stale alias behind.
|
||||
*/
|
||||
private fun canonicalizePrivateConversationStateLocked() {
|
||||
val canonicalChats = ContactDirectory.canonicalizePrivateChats(_privateMessages.value)
|
||||
if (canonicalChats != _privateMessages.value) {
|
||||
_privateMessages.value = canonicalChats
|
||||
}
|
||||
|
||||
val canonicalUnreadCounts = linkedMapOf<String, Int>()
|
||||
_unreadPrivateMessageCounts.value.forEach { (conversationID, count) ->
|
||||
if (count <= 0) return@forEach
|
||||
val canonicalID = ContactDirectory.canonicalConversationId(conversationID)
|
||||
canonicalUnreadCounts[canonicalID] =
|
||||
(canonicalUnreadCounts[canonicalID] ?: 0) + count
|
||||
}
|
||||
if (canonicalUnreadCounts != _unreadPrivateMessageCounts.value) {
|
||||
_unreadPrivateMessageCounts.value = canonicalUnreadCounts
|
||||
}
|
||||
|
||||
val canonicalDisplayNames = linkedMapOf<String, String>()
|
||||
_privateConversationDisplayNames.value.forEach { (conversationID, displayName) ->
|
||||
if (displayName.isBlank()) return@forEach
|
||||
canonicalDisplayNames[
|
||||
ContactDirectory.canonicalConversationId(conversationID)
|
||||
] = displayName
|
||||
}
|
||||
if (canonicalDisplayNames != _privateConversationDisplayNames.value) {
|
||||
_privateConversationDisplayNames.value = canonicalDisplayNames
|
||||
}
|
||||
|
||||
_selectedPrivateChatPeer.value?.let { selected ->
|
||||
val canonicalSelected = ContactDirectory.canonicalConversationId(selected)
|
||||
if (canonicalSelected != selected) {
|
||||
_selectedPrivateChatPeer.value = canonicalSelected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prunePrivateMessagesLocked(recentConversationID: String) {
|
||||
val chats = _privateMessages.value.toMutableMap()
|
||||
val removedIDs = linkedSetOf<String>()
|
||||
val recentKey = chats.keys.firstOrNull { key ->
|
||||
ContactDirectory.canonicalConversationId(key)
|
||||
.equals(recentConversationID, ignoreCase = true)
|
||||
}
|
||||
if (recentKey != null) {
|
||||
val messages = chats[recentKey].orEmpty()
|
||||
val excess =
|
||||
messages.size - ConversationDatabase.MAX_MESSAGES_PER_CONVERSATION
|
||||
if (excess > 0) {
|
||||
val removable = messages
|
||||
.dropLast(1)
|
||||
.sortedWith(privateMessagePruneComparator())
|
||||
.take(excess)
|
||||
.mapTo(linkedSetOf()) { it.id }
|
||||
removedIDs.addAll(removable)
|
||||
chats[recentKey] = messages.filterNot { it.id in removable }
|
||||
}
|
||||
}
|
||||
|
||||
privateWritesSinceGlobalPrune += 1
|
||||
if (privateWritesSinceGlobalPrune >= 64) {
|
||||
privateWritesSinceGlobalPrune = 0
|
||||
var totalMessages = chats.values.sumOf { it.size }
|
||||
var totalPayloadBytes = chats.values
|
||||
.asSequence()
|
||||
.flatten()
|
||||
.sumOf(::privateMessagePayloadBytes)
|
||||
if (
|
||||
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
|
||||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
|
||||
) {
|
||||
val candidates = chats.values
|
||||
.asSequence()
|
||||
.flatMap { messages -> messages.dropLast(1).asSequence() }
|
||||
.sortedWith(privateMessagePruneComparator())
|
||||
.iterator()
|
||||
while (
|
||||
candidates.hasNext() &&
|
||||
(
|
||||
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
|
||||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
|
||||
)
|
||||
) {
|
||||
val candidate = candidates.next()
|
||||
if (!removedIDs.add(candidate.id)) continue
|
||||
totalMessages -= 1
|
||||
totalPayloadBytes -= privateMessagePayloadBytes(candidate)
|
||||
}
|
||||
if (
|
||||
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
|
||||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
|
||||
) {
|
||||
// Enforce the hard bound even when every conversation contains only its
|
||||
// newest message. Read conversations still sort ahead of unread ones.
|
||||
val latestCandidates = chats.values
|
||||
.asSequence()
|
||||
.flatten()
|
||||
.filterNot { it.id in removedIDs }
|
||||
.sortedWith(privateMessagePruneComparator())
|
||||
.iterator()
|
||||
while (
|
||||
latestCandidates.hasNext() &&
|
||||
(
|
||||
totalMessages > ConversationDatabase.MAX_MESSAGES_TOTAL ||
|
||||
totalPayloadBytes > ConversationDatabase.MAX_PAYLOAD_BYTES
|
||||
)
|
||||
) {
|
||||
val candidate = latestCandidates.next()
|
||||
if (!removedIDs.add(candidate.id)) continue
|
||||
totalMessages -= 1
|
||||
totalPayloadBytes -= privateMessagePayloadBytes(candidate)
|
||||
}
|
||||
}
|
||||
if (removedIDs.isNotEmpty()) {
|
||||
chats.keys.toList().forEach { key ->
|
||||
chats[key] = chats[key].orEmpty().filterNot {
|
||||
it.id in removedIDs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (removedIDs.isNotEmpty()) {
|
||||
_privateMessages.value = chats.filterValues { it.isNotEmpty() }
|
||||
_readPrivateMessageIDs.value = _readPrivateMessageIDs.value - removedIDs
|
||||
}
|
||||
}
|
||||
|
||||
private fun privateMessagePruneComparator(): Comparator<BitchatMessage> =
|
||||
compareByDescending<BitchatMessage> {
|
||||
it.id in _readPrivateMessageIDs.value
|
||||
}.thenBy {
|
||||
PrivateMessageArrivalOrder.sequenceOf(it.id) ?: Long.MAX_VALUE
|
||||
}
|
||||
|
||||
private fun privateMessagePayloadBytes(message: BitchatMessage): Long =
|
||||
message.content.toByteArray(Charsets.UTF_8).size.toLong() +
|
||||
(message.encryptedContent?.size ?: 0) +
|
||||
message.mentions.orEmpty().sumOf {
|
||||
it.toByteArray(Charsets.UTF_8).size
|
||||
}
|
||||
}
|
||||
|
||||
private data class PendingPrivateMessagePersistence(
|
||||
val repository: ConversationRepository?,
|
||||
val conversationID: String,
|
||||
val aliases: Set<String>,
|
||||
val displayName: String?,
|
||||
val isRead: Boolean,
|
||||
val generation: Long
|
||||
)
|
||||
|
||||
internal data class DeletedPrivateConversation(
|
||||
val conversationID: String,
|
||||
val aliases: Set<String>,
|
||||
val displayName: String?,
|
||||
val messages: List<BitchatMessage>,
|
||||
val readMessageIDs: Set<String>,
|
||||
val unreadMessageCount: Int,
|
||||
val wasPinned: Boolean = false,
|
||||
val wasMuted: Boolean = false,
|
||||
val draft: String? = null
|
||||
) {
|
||||
val messageIDs: Set<String> = messages.mapTo(linkedSetOf(), BitchatMessage::id)
|
||||
}
|
||||
|
||||
private val EMPTY_CONVERSATION_STORE_STATE =
|
||||
MutableStateFlow<ConversationStoreState>(ConversationStoreState.Ready).asStateFlow()
|
||||
|
||||
@ -81,9 +81,19 @@ object ContactDirectory {
|
||||
conversationID = conversationID,
|
||||
meshPeerID = liveMeshPeerID,
|
||||
noisePublicKey = noiseKey ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.noisePublicKey },
|
||||
nostrPubkey = favorite?.peerNostrPublicKey,
|
||||
displayName = favorite?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname }
|
||||
nostrPubkey = favorite?.peerNostrPublicKey
|
||||
?: liveMeshPeerID?.let {
|
||||
runCatching {
|
||||
FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(it)
|
||||
}.getOrNull()
|
||||
},
|
||||
// A connected peer's current announcement is authoritative. Favorite and fingerprint
|
||||
// records are offline fallbacks and can legitimately contain an older nickname.
|
||||
displayName = liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname }
|
||||
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
?: favorite?.peerNickname?.takeIf {
|
||||
it.isNotBlank() && !it.equals("Unknown", ignoreCase = true)
|
||||
}
|
||||
?: contactFingerprint?.let { cachedFingerprintNickname(it) },
|
||||
isMutualFavorite = favorite?.isMutual == true
|
||||
)
|
||||
|
||||
@ -0,0 +1,194 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.content.Context
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Small encrypted, immediately observable preferences for conversation-list organization.
|
||||
*
|
||||
* Message history remains in SQLite; these compact sets and drafts belong in the app's existing
|
||||
* Keystore-backed preference store. Panic clearing the identity store also removes these values.
|
||||
*/
|
||||
internal class ConversationListPreferences private constructor(
|
||||
private val stateManager: SecureIdentityStateManager,
|
||||
private val canonicalize: (String) -> String
|
||||
) {
|
||||
private constructor(context: Context) : this(
|
||||
SecureIdentityStateManager(context.applicationContext),
|
||||
ContactDirectory::canonicalConversationId
|
||||
)
|
||||
|
||||
internal constructor(
|
||||
stateManager: SecureIdentityStateManager,
|
||||
testOnly: Boolean,
|
||||
canonicalize: (String) -> String = ContactDirectory::canonicalConversationId
|
||||
) : this(stateManager, canonicalize) {
|
||||
require(testOnly) { "Injected conversation preferences are test-only" }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PINNED_KEY = "conversation_pinned_v1"
|
||||
private const val MUTED_KEY = "conversation_muted_v1"
|
||||
private const val DRAFTS_KEY = "conversation_drafts_v1"
|
||||
private const val MAX_DRAFT_CHARS = 8_000
|
||||
private const val MAX_DRAFTS = 50
|
||||
private const val MAX_DRAFT_CHARS_TOTAL = 128_000
|
||||
|
||||
@Volatile
|
||||
private var instance: ConversationListPreferences? = null
|
||||
|
||||
fun getInstance(context: Context): ConversationListPreferences =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: ConversationListPreferences(context.applicationContext).also {
|
||||
instance = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _pinned = MutableStateFlow(loadSet(PINNED_KEY))
|
||||
val pinned: StateFlow<Set<String>> = _pinned.asStateFlow()
|
||||
private val _muted = MutableStateFlow(loadSet(MUTED_KEY))
|
||||
val muted: StateFlow<Set<String>> = _muted.asStateFlow()
|
||||
private val _drafts = MutableStateFlow(loadDrafts())
|
||||
val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
|
||||
|
||||
fun togglePinned(conversationID: String) {
|
||||
_pinned.value = _pinned.value.toggle(normalize(conversationID))
|
||||
saveSet(PINNED_KEY, _pinned.value)
|
||||
}
|
||||
|
||||
fun toggleMuted(conversationID: String) {
|
||||
_muted.value = _muted.value.toggle(normalize(conversationID))
|
||||
saveSet(MUTED_KEY, _muted.value)
|
||||
}
|
||||
|
||||
fun isMuted(conversationID: String): Boolean =
|
||||
normalize(conversationID) in _muted.value
|
||||
|
||||
fun isPinned(conversationID: String): Boolean =
|
||||
normalize(conversationID) in _pinned.value
|
||||
|
||||
fun draftFor(conversationID: String): String? =
|
||||
_drafts.value[normalize(conversationID)]
|
||||
|
||||
fun setDraft(conversationID: String, text: String) {
|
||||
val key = normalize(conversationID)
|
||||
val updated = _drafts.value.toMutableMap()
|
||||
// Reinsert edited drafts at the end so bounded eviction approximates least-recently-used.
|
||||
updated.remove(key)
|
||||
val bounded = text.take(MAX_DRAFT_CHARS)
|
||||
if (bounded.isNotBlank()) updated[key] = bounded
|
||||
val retained = boundDrafts(updated)
|
||||
_drafts.value = retained
|
||||
saveDrafts(retained)
|
||||
}
|
||||
|
||||
fun removeConversation(conversationID: String) {
|
||||
val key = normalize(conversationID)
|
||||
_pinned.value = _pinned.value - key
|
||||
_muted.value = _muted.value - key
|
||||
_drafts.value = _drafts.value - key
|
||||
saveSet(PINNED_KEY, _pinned.value)
|
||||
saveSet(MUTED_KEY, _muted.value)
|
||||
saveDrafts(_drafts.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-key list preferences when a transient mesh ID becomes a stable contact identity.
|
||||
* Without this, pin, mute, and draft state appears to disappear after a Noise/favorite update.
|
||||
*/
|
||||
fun canonicalizeAliases() {
|
||||
val canonicalPinned = _pinned.value.mapTo(linkedSetOf(), ::normalize)
|
||||
val canonicalMuted = _muted.value.mapTo(linkedSetOf(), ::normalize)
|
||||
val canonicalDrafts = linkedMapOf<String, String>()
|
||||
_drafts.value.forEach { (conversationID, draft) ->
|
||||
canonicalDrafts[normalize(conversationID)] = draft
|
||||
}
|
||||
|
||||
if (canonicalPinned != _pinned.value) {
|
||||
_pinned.value = canonicalPinned
|
||||
saveSet(PINNED_KEY, canonicalPinned)
|
||||
}
|
||||
if (canonicalMuted != _muted.value) {
|
||||
_muted.value = canonicalMuted
|
||||
saveSet(MUTED_KEY, canonicalMuted)
|
||||
}
|
||||
if (canonicalDrafts != _drafts.value) {
|
||||
val bounded = boundDrafts(canonicalDrafts)
|
||||
_drafts.value = bounded
|
||||
saveDrafts(bounded)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearInMemory() {
|
||||
_pinned.value = emptySet()
|
||||
_muted.value = emptySet()
|
||||
_drafts.value = emptyMap()
|
||||
}
|
||||
|
||||
fun clearAll(): Boolean {
|
||||
val cleared = stateManager.clearSecureValuesSynchronously(
|
||||
PINNED_KEY,
|
||||
MUTED_KEY,
|
||||
DRAFTS_KEY
|
||||
)
|
||||
clearInMemory()
|
||||
return cleared
|
||||
}
|
||||
|
||||
private fun loadSet(key: String): Set<String> = runCatching {
|
||||
val array = JSONArray(stateManager.getSecureValue(key) ?: return emptySet())
|
||||
buildSet {
|
||||
for (index in 0 until array.length()) add(normalize(array.getString(index)))
|
||||
}
|
||||
}.getOrDefault(emptySet())
|
||||
|
||||
private fun saveSet(key: String, values: Set<String>) {
|
||||
stateManager.storeSecureValue(key, JSONArray(values.sorted()).toString())
|
||||
}
|
||||
|
||||
private fun loadDrafts(): Map<String, String> = runCatching {
|
||||
val json = JSONObject(stateManager.getSecureValue(DRAFTS_KEY) ?: return emptyMap())
|
||||
val loaded = buildMap {
|
||||
json.keys().forEach { key ->
|
||||
json.optString(key).takeIf(String::isNotBlank)?.let {
|
||||
put(normalize(key), it.take(MAX_DRAFT_CHARS))
|
||||
}
|
||||
}
|
||||
}
|
||||
boundDrafts(loaded)
|
||||
}.getOrDefault(emptyMap())
|
||||
|
||||
private fun saveDrafts(values: Map<String, String>) {
|
||||
stateManager.storeSecureValue(
|
||||
DRAFTS_KEY,
|
||||
JSONObject().apply {
|
||||
values.forEach { (key, value) -> put(key, value) }
|
||||
}.toString()
|
||||
)
|
||||
}
|
||||
|
||||
private fun Set<String>.toggle(value: String): Set<String> =
|
||||
if (value in this) this - value else this + value
|
||||
|
||||
private fun normalize(value: String): String =
|
||||
canonicalize(value).lowercase()
|
||||
|
||||
private fun boundDrafts(values: Map<String, String>): Map<String, String> {
|
||||
val retained = LinkedHashMap(values)
|
||||
while (
|
||||
retained.size > MAX_DRAFTS ||
|
||||
retained.values.sumOf(String::length) > MAX_DRAFT_CHARS_TOTAL
|
||||
) {
|
||||
val oldest = retained.keys.firstOrNull() ?: break
|
||||
retained.remove(oldest)
|
||||
}
|
||||
return retained
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,98 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
/**
|
||||
* Encrypts private-conversation payloads with a key that never leaves Android Keystore.
|
||||
*
|
||||
* Every value is bound to its database identity through AES-GCM associated data. This prevents an
|
||||
* encrypted payload copied from one message or conversation row from being accepted in another.
|
||||
* Deleting the dedicated alias provides practical cryptographic erasure before SQLite pages, WAL
|
||||
* records, and filesystem blocks are reclaimed.
|
||||
*/
|
||||
internal interface ConversationStorageCipher {
|
||||
fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray
|
||||
fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray
|
||||
fun destroyKey()
|
||||
}
|
||||
internal class AndroidConversationStorageCipher(
|
||||
private val keyAlias: String = DEFAULT_KEY_ALIAS
|
||||
) : ConversationStorageCipher {
|
||||
companion object {
|
||||
internal const val DEFAULT_KEY_ALIAS = "bitchat_conversation_storage_v1"
|
||||
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val ENVELOPE_VERSION: Byte = 1
|
||||
private const val GCM_TAG_BITS = 128
|
||||
private const val IV_BYTES = 12
|
||||
}
|
||||
|
||||
private val keyLock = Any()
|
||||
@Volatile
|
||||
private var cachedKey: SecretKey? = null
|
||||
|
||||
override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||
cipher.updateAAD(associatedData)
|
||||
val ciphertext = cipher.doFinal(plaintext)
|
||||
check(cipher.iv.size == IV_BYTES) { "Unexpected AES-GCM IV length" }
|
||||
return byteArrayOf(ENVELOPE_VERSION) + cipher.iv + ciphertext
|
||||
}
|
||||
|
||||
override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray {
|
||||
require(envelope.size > 1 + IV_BYTES) { "Conversation payload envelope is truncated" }
|
||||
require(envelope[0] == ENVELOPE_VERSION) {
|
||||
"Unsupported conversation payload envelope version"
|
||||
}
|
||||
val iv = envelope.copyOfRange(1, 1 + IV_BYTES)
|
||||
val ciphertext = envelope.copyOfRange(1 + IV_BYTES, envelope.size)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
|
||||
cipher.updateAAD(associatedData)
|
||||
return cipher.doFinal(ciphertext)
|
||||
}
|
||||
|
||||
override fun destroyKey() {
|
||||
synchronized(keyLock) {
|
||||
cachedKey = null
|
||||
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
|
||||
if (keyStore.containsAlias(keyAlias)) {
|
||||
keyStore.deleteEntry(keyAlias)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateKey(): SecretKey =
|
||||
cachedKey ?: synchronized(keyLock) {
|
||||
cachedKey ?: run {
|
||||
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
|
||||
(keyStore.getKey(keyAlias, null) as? SecretKey) ?: generateKey()
|
||||
}.also { cachedKey = it }
|
||||
}
|
||||
|
||||
private fun generateKey(): SecretKey {
|
||||
val generator = KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES,
|
||||
KEYSTORE_PROVIDER
|
||||
)
|
||||
generator.init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
keyAlias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build()
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* Reflects an incoming transport message into process-wide state before any downstream effects.
|
||||
*
|
||||
* Private-message admission is authoritative: a duplicate or a message rejected while panic mode
|
||||
* is wiping state must not continue to UI delegates, unread tracking, haptics, or notifications.
|
||||
* Public and channel messages retain their existing best-effort behavior if state reflection fails.
|
||||
*/
|
||||
internal object IncomingMessageAdmission {
|
||||
fun admitToAppState(message: BitchatMessage): Boolean = try {
|
||||
when {
|
||||
message.isPrivate -> {
|
||||
val peerID = message.senderPeerID?.takeIf(String::isNotBlank)
|
||||
?: return false
|
||||
// Mesh transport callbacks run on their background service workers. Wait for the
|
||||
// serialized SQLite transaction so a notification can never advertise a message
|
||||
// that an immediate process death would lose.
|
||||
runBlocking {
|
||||
AppStateStore.addPrivateMessageDurably(peerID, message)
|
||||
}
|
||||
}
|
||||
|
||||
message.channel != null -> {
|
||||
AppStateStore.addChannelMessage(message.channel, message)
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
AppStateStore.addPublicMessage(message)
|
||||
true
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Preserve the pre-existing best-effort dispatch for public/channel messages, but never
|
||||
// bypass private-message admission when persistence or canonicalization fails.
|
||||
!message.isPrivate
|
||||
}
|
||||
}
|
||||
@ -99,6 +99,12 @@ class MessageRouter private constructor(
|
||||
startOutboxScheduler()
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
outbox.clear()
|
||||
retryState.clear()
|
||||
Log.d(TAG, "Cleared all MessageRouter outbox messages and retry state")
|
||||
}
|
||||
|
||||
// Listener for favorites changes to flush outbox when npub mapping appears/changes
|
||||
private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener {
|
||||
|
||||
|
||||
@ -11,16 +11,56 @@ import com.bitchat.android.model.BitchatMessage
|
||||
*/
|
||||
internal object PrivateMessageArrivalOrder {
|
||||
private val sequenceByMessageID = mutableMapOf<String, Long>()
|
||||
private val receivedAtByMessageID = mutableMapOf<String, Long>()
|
||||
private var nextSequence = 0L
|
||||
|
||||
fun record(messageID: String) {
|
||||
fun record(messageID: String, receivedAt: Long = System.currentTimeMillis()) {
|
||||
synchronized(this) {
|
||||
if (messageID !in sequenceByMessageID) {
|
||||
sequenceByMessageID[messageID] = nextSequence++
|
||||
receivedAtByMessageID[messageID] = receivedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restore(
|
||||
persistedOrder: List<String>,
|
||||
liveMessageIDs: List<String>,
|
||||
persistedReceivedAt: Map<String, Long> = emptyMap(),
|
||||
persistedSequences: Map<String, Long> = emptyMap()
|
||||
) {
|
||||
synchronized(this) {
|
||||
val previousSequences = sequenceByMessageID.toMap()
|
||||
val previousReceivedAt = receivedAtByMessageID.toMap()
|
||||
sequenceByMessageID.clear()
|
||||
receivedAtByMessageID.clear()
|
||||
nextSequence = (persistedSequences.values.maxOrNull() ?: -1L) + 1L
|
||||
(persistedOrder + liveMessageIDs).forEach { messageID ->
|
||||
if (messageID !in sequenceByMessageID) {
|
||||
val sequence = persistedSequences[messageID]
|
||||
?: previousSequences[messageID]
|
||||
?: nextSequence++
|
||||
sequenceByMessageID[messageID] = sequence
|
||||
(persistedReceivedAt[messageID] ?: previousReceivedAt[messageID])?.let {
|
||||
receivedAtByMessageID[messageID] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
nextSequence = maxOf(
|
||||
nextSequence,
|
||||
(sequenceByMessageID.values.maxOrNull() ?: -1L) + 1L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun sequenceOf(messageID: String): Long? = synchronized(this) {
|
||||
sequenceByMessageID[messageID]
|
||||
}
|
||||
|
||||
fun receivedAtOf(messageID: String): Long? = synchronized(this) {
|
||||
receivedAtByMessageID[messageID]
|
||||
}
|
||||
|
||||
fun order(messages: List<BitchatMessage>): List<BitchatMessage> {
|
||||
synchronized(this) {
|
||||
if (messages.size < 2 || messages.any { it.id !in sequenceByMessageID }) {
|
||||
@ -33,6 +73,7 @@ internal object PrivateMessageArrivalOrder {
|
||||
fun clear() {
|
||||
synchronized(this) {
|
||||
sequenceByMessageID.clear()
|
||||
receivedAtByMessageID.clear()
|
||||
nextSequence = 0L
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
@ -20,6 +21,8 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
private const val STORAGE_KEY = "seen_message_store_v1"
|
||||
private const val MAX_IDS = com.bitchat.android.util.AppConstants.Services.SEEN_MESSAGE_MAX_IDS
|
||||
|
||||
// The constructor always receives applicationContext, so process lifetime is intentional.
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
@Volatile private var INSTANCE: SeenMessageStore? = null
|
||||
fun getInstance(appContext: Context): SeenMessageStore {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
@ -54,6 +57,7 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
locallyRead.add(id)
|
||||
trim(locallyRead)
|
||||
}
|
||||
AppStateStore.markPrivateMessageRead(id)
|
||||
persist()
|
||||
}
|
||||
|
||||
@ -65,6 +69,14 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
persist()
|
||||
}
|
||||
|
||||
@Synchronized fun remove(ids: Set<String>) {
|
||||
if (ids.isEmpty()) return
|
||||
delivered.removeAll(ids)
|
||||
locallyRead.removeAll(ids)
|
||||
readReceiptsSent.removeAll(ids)
|
||||
persist()
|
||||
}
|
||||
|
||||
@Synchronized fun clear() {
|
||||
delivered.clear()
|
||||
locallyRead.clear()
|
||||
|
||||
@ -67,6 +67,11 @@ class MeshGraphService private constructor() {
|
||||
nicknames.remove(peerID)
|
||||
announcements.remove(peerID)
|
||||
lastUpdate.remove(peerID)
|
||||
announcements.keys.toList().forEach { originPeerID ->
|
||||
announcements.computeIfPresent(originPeerID) { _, neighbors ->
|
||||
neighbors - peerID
|
||||
}
|
||||
}
|
||||
publishSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,6 +79,15 @@ class GossipSyncManager(
|
||||
cleanupJob?.cancel(); cleanupJob = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
synchronized(messages) {
|
||||
messages.clear()
|
||||
}
|
||||
latestAnnouncementByPeer.clear()
|
||||
Log.d(TAG, "Cleared all gossip sync messages and announcements")
|
||||
}
|
||||
|
||||
fun scheduleInitialSync(delayMs: Long = 5_000L) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(delayMs)
|
||||
|
||||
@ -20,6 +20,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@ -35,11 +36,16 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.UnfoldMore
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
@ -52,6 +58,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@ -62,6 +70,7 @@ import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.util.downloadPhaseLabel
|
||||
import com.bitchat.android.hotspot.HotspotActivity
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
import com.bitchat.android.net.TorMode
|
||||
@ -121,6 +130,69 @@ private fun ThemeChip(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageSettingsRow(
|
||||
selectedLanguageName: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 15.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.about_app_language),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = selectedLanguageName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.UnfoldMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LanguageMenuItem(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
trailingIcon = {
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified settings toggle row with icon, title, subtitle, and switch
|
||||
* Apple-like design with proper spacing
|
||||
@ -260,6 +332,13 @@ fun AboutSheet(
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val palette = LocalBitchatPalette.current
|
||||
var selectedTab by remember { mutableStateOf(AboutTab.Info) }
|
||||
val supportedLanguages = remember(context) {
|
||||
LanguagePreferenceManager.supportedLanguages(context)
|
||||
}
|
||||
var selectedLanguageTag by remember {
|
||||
mutableStateOf(LanguagePreferenceManager.currentLanguageTag())
|
||||
}
|
||||
var showLanguagePicker by remember { mutableStateOf(false) }
|
||||
|
||||
if (isPresented) {
|
||||
BitchatBottomSheet(
|
||||
@ -307,6 +386,7 @@ fun AboutSheet(
|
||||
Column {
|
||||
AboutSectionLabel(text = stringResource(R.string.about_section_theme))
|
||||
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState()
|
||||
val chatUiMode by com.bitchat.android.ui.theme.ChatUiModeManager.modeFlow.collectAsState()
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -314,30 +394,110 @@ fun AboutSheet(
|
||||
color = colorScheme.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Row(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_system),
|
||||
selected = themePref.isSystem,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) },
|
||||
modifier = Modifier.weight(1f)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_system),
|
||||
selected = themePref.isSystem,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_light),
|
||||
selected = themePref.isLight,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_dark),
|
||||
selected = themePref.isDark,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.chat_ui_bubbles),
|
||||
selected = chatUiMode.isBubbles,
|
||||
onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Bubbles) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.chat_ui_matrix),
|
||||
selected = chatUiMode.isMatrix,
|
||||
onClick = { com.bitchat.android.ui.theme.ChatUiModeManager.set(context, com.bitchat.android.ui.theme.ChatUiMode.Matrix) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "language") {
|
||||
val selectedLanguageName = supportedLanguages
|
||||
.firstOrNull { it.languageTag == selectedLanguageTag }
|
||||
?.endonym
|
||||
?: stringResource(R.string.about_system_default)
|
||||
|
||||
Column {
|
||||
AboutSectionLabel(text = stringResource(R.string.about_language))
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surface,
|
||||
shape = AboutCardShape,
|
||||
) {
|
||||
LanguageSettingsRow(
|
||||
selectedLanguageName = selectedLanguageName,
|
||||
onClick = { showLanguagePicker = true },
|
||||
)
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_light),
|
||||
selected = themePref.isLight,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
ThemeChip(
|
||||
label = stringResource(R.string.about_dark),
|
||||
selected = themePref.isDark,
|
||||
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) },
|
||||
modifier = Modifier.weight(1f)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showLanguagePicker,
|
||||
onDismissRequest = { showLanguagePicker = false },
|
||||
modifier = Modifier.width(maxWidth),
|
||||
) {
|
||||
LanguageMenuItem(
|
||||
label = stringResource(R.string.about_system_default),
|
||||
selected = selectedLanguageTag.isEmpty(),
|
||||
onClick = {
|
||||
showLanguagePicker = false
|
||||
if (selectedLanguageTag.isNotEmpty()) {
|
||||
selectedLanguageTag = ""
|
||||
LanguagePreferenceManager.setLanguage("")
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider()
|
||||
supportedLanguages.forEach { language ->
|
||||
LanguageMenuItem(
|
||||
label = language.endonym,
|
||||
selected = selectedLanguageTag == language.languageTag,
|
||||
onClick = {
|
||||
showLanguagePicker = false
|
||||
if (language.languageTag != selectedLanguageTag) {
|
||||
selectedLanguageTag = language.languageTag
|
||||
LanguagePreferenceManager.setLanguage(language.languageTag)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -349,6 +509,9 @@ fun AboutSheet(
|
||||
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
|
||||
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
|
||||
var backgroundEnabled by remember { mutableStateOf(com.bitchat.android.service.MeshServicePreferences.isBackgroundEnabled(true)) }
|
||||
var liveVoiceEnabled by remember {
|
||||
mutableStateOf(com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context))
|
||||
}
|
||||
val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) }
|
||||
val torProvider = remember { ArtiTorManager.getInstance() }
|
||||
val torStatus by torProvider.statusFlow.collectAsState()
|
||||
@ -387,6 +550,23 @@ fun AboutSheet(
|
||||
color = colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon = Icons.Filled.Mic,
|
||||
title = "Live push-to-talk",
|
||||
subtitle = "Play voice bursts live on the mesh; voice notes are always sent on release",
|
||||
checked = liveVoiceEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
liveVoiceEnabled = enabled
|
||||
com.bitchat.android.features.voice.LiveVoicePreferences.setEnabled(context, enabled)
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 54.dp),
|
||||
thickness = 1.dp,
|
||||
color = colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
// Proof of Work Toggle
|
||||
SettingsToggleRow(
|
||||
icon = Icons.Filled.Speed,
|
||||
@ -440,7 +620,16 @@ fun AboutSheet(
|
||||
val apkViewModel: ApkDownloadViewModel = viewModel()
|
||||
val apkUiState by apkViewModel.state.collectAsStateWithLifecycle()
|
||||
val apkStatus = apkUiState.apkStatus
|
||||
val releaseStatus = apkUiState.releaseStatus
|
||||
val downloadProgress = apkUiState.downloadProgress
|
||||
val shareableApk = when (apkStatus) {
|
||||
is ApkPreparationStatus.Ready -> apkStatus
|
||||
is ApkPreparationStatus.Downloading ->
|
||||
apkStatus.shareableFallback
|
||||
else -> null
|
||||
}
|
||||
val availableUpdate = (releaseStatus as? ApkReleaseStatus.Known)
|
||||
?.takeIf { it.isNewerThanSharedApk }
|
||||
|
||||
// Handle one-shot effects (navigation, toasts, share intents)
|
||||
LaunchedEffect(Unit) {
|
||||
@ -475,14 +664,22 @@ fun AboutSheet(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = apkStatus !is ApkPreparationStatus.Downloading) {
|
||||
// Enabled by the same mapping that decides what the tap
|
||||
// does, so the row can never look tappable and do
|
||||
// nothing.
|
||||
.clickable(
|
||||
enabled = prepareRowTapAction(
|
||||
apkStatus,
|
||||
releaseStatus
|
||||
) != null
|
||||
) {
|
||||
apkViewModel.onEvent(ApkUiEvent.PrepareRowClicked)
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (apkStatus is ApkPreparationStatus.Ready) {
|
||||
imageVector = if (shareableApk != null) {
|
||||
Icons.Default.Share
|
||||
} else {
|
||||
Icons.Default.CloudDownload
|
||||
@ -498,140 +695,210 @@ fun AboutSheet(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (apkStatus is ApkPreparationStatus.Ready) {
|
||||
stringResource(R.string.prepare_apk_ready_title)
|
||||
} else {
|
||||
stringResource(R.string.prepare_apk_title)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = if (shareableApk != null) {
|
||||
stringResource(R.string.prepare_apk_ready_title)
|
||||
} else {
|
||||
stringResource(R.string.prepare_apk_title)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
if (availableUpdate != null) {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults
|
||||
.rememberTooltipPositionProvider(),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.prepare_apk_update_available,
|
||||
availableUpdate.version
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = stringResource(
|
||||
R.string.prepare_apk_update_warning
|
||||
),
|
||||
tint = colorScheme.tertiary,
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.size(18.dp)
|
||||
.clickable {
|
||||
apkViewModel.onEvent(
|
||||
ApkUiEvent.DownloadUniversalClicked
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = when (val status = apkStatus) {
|
||||
is ApkPreparationStatus.Loading -> stringResource(R.string.checking)
|
||||
is ApkPreparationStatus.NotDownloaded -> stringResource(R.string.prepare_apk_status_not_downloaded)
|
||||
is ApkPreparationStatus.NotDownloaded ->
|
||||
stringResource(
|
||||
R.string.prepare_apk_status_not_downloaded
|
||||
)
|
||||
is ApkPreparationStatus.Ready -> {
|
||||
val source = when {
|
||||
status.source == UniversalApkManager.ApkSource.GITHUB ->
|
||||
stringResource(R.string.prepare_apk_source_github)
|
||||
status.source == UniversalApkManager.ApkSource.DOWNLOADED ->
|
||||
stringResource(R.string.prepare_apk_source_downloaded)
|
||||
status.variant == ShareableApkVariant.ARM64 ->
|
||||
stringResource(R.string.prepare_apk_source_installed_arm64)
|
||||
else ->
|
||||
stringResource(R.string.prepare_apk_source_installed)
|
||||
}
|
||||
stringResource(R.string.prepare_apk_status_ready) +
|
||||
" • ${status.version} • ${status.sizeMB} MB\n$source"
|
||||
stringResource(
|
||||
R.string.prepare_apk_ready_detail,
|
||||
status.version,
|
||||
status.sizeMB,
|
||||
source
|
||||
)
|
||||
}
|
||||
is ApkPreparationStatus.UpdateAvailable -> stringResource(R.string.prepare_apk_status_update_available) + " (${status.newVersion})"
|
||||
is ApkPreparationStatus.Downloading -> stringResource(R.string.prepare_apk_status_downloading, downloadProgress)
|
||||
is ApkPreparationStatus.Resumable -> "Tap to resume • ${status.progressPercent}% downloaded"
|
||||
is ApkPreparationStatus.Error -> status.message
|
||||
is ApkPreparationStatus.Downloading ->
|
||||
// Only the transfer has a percentage worth
|
||||
// showing; the other phases are named
|
||||
// instead of pretending to be at 0%.
|
||||
if (status.phase.hasMeasurableProgress) {
|
||||
stringResource(R.string.prepare_apk_status_downloading, downloadProgress)
|
||||
} else {
|
||||
stringResource(downloadPhaseLabel(status.phase))
|
||||
}
|
||||
is ApkPreparationStatus.Resumable ->
|
||||
stringResource(
|
||||
R.string.prepare_apk_status_resumable,
|
||||
context.resolveApkFailureMessage(
|
||||
status.failure
|
||||
),
|
||||
status.progressPercent
|
||||
)
|
||||
is ApkPreparationStatus.Error ->
|
||||
context.resolveApkFailureMessage(
|
||||
status.failure
|
||||
)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when (apkStatus) {
|
||||
is ApkPreparationStatus.Error -> colorScheme.error
|
||||
is ApkPreparationStatus.Resumable -> colorScheme.primary
|
||||
is ApkPreparationStatus.UpdateAvailable -> colorScheme.primary
|
||||
else -> colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
},
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
|
||||
// Progress lives in the column, not the trailing slot,
|
||||
// which leaves that slot free for a single control.
|
||||
ApkDownloadProgressBar(
|
||||
status = apkStatus,
|
||||
progressPercent = downloadProgress
|
||||
)
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
// One control, one width, in every state. The progress
|
||||
// readout moved into the column above, so nothing else
|
||||
// competes for this slot.
|
||||
when (apkStatus) {
|
||||
is ApkPreparationStatus.Downloading -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp
|
||||
is ApkPreparationStatus.Downloading ->
|
||||
ApkPrepareRowIconButton(
|
||||
icon = Icons.Default.Close,
|
||||
description = stringResource(
|
||||
R.string.prepare_apk_stop
|
||||
),
|
||||
onClick = {
|
||||
apkViewModel.onEvent(
|
||||
ApkUiEvent.CancelDownload
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
is ApkPreparationStatus.Ready -> {
|
||||
if (apkStatus.variant == ShareableApkVariant.ARM64) {
|
||||
TextButton(
|
||||
if (
|
||||
apkStatus.source ==
|
||||
UniversalApkManager.ApkSource.INSTALLED
|
||||
) {
|
||||
ApkPrepareRowIconButton(
|
||||
icon = Icons.Default.CloudDownload,
|
||||
description = stringResource(
|
||||
R.string.prepare_apk_get_universal
|
||||
),
|
||||
onClick = {
|
||||
apkViewModel.onEvent(
|
||||
ApkUiEvent.DownloadUniversalClicked
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CloudDownload,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.prepare_apk_get_universal
|
||||
)
|
||||
)
|
||||
}
|
||||
} else if (apkStatus.source == UniversalApkManager.ApkSource.GITHUB) {
|
||||
androidx.compose.material3.IconButton(
|
||||
onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) },
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(
|
||||
R.string.prepare_apk_delete_confirm
|
||||
),
|
||||
tint = colorScheme.error,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is ApkPreparationStatus.UpdateAvailable -> {
|
||||
androidx.compose.material3.IconButton(
|
||||
onClick = { apkViewModel.onEvent(ApkUiEvent.DeleteClicked) },
|
||||
modifier = Modifier.size(48.dp)
|
||||
},
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
} else if (
|
||||
apkStatus.source ==
|
||||
UniversalApkManager.ApkSource.DOWNLOADED
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(
|
||||
R.string.prepare_apk_delete_confirm
|
||||
ApkPrepareRowIconButton(
|
||||
icon = Icons.Default.Delete,
|
||||
description = stringResource(
|
||||
R.string.prepare_apk_button_delete
|
||||
),
|
||||
tint = colorScheme.error,
|
||||
modifier = Modifier.size(20.dp)
|
||||
onClick = {
|
||||
apkViewModel.onEvent(
|
||||
ApkUiEvent.DeleteClicked
|
||||
)
|
||||
},
|
||||
tint = colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
is ApkPreparationStatus.Resumable,
|
||||
is ApkPreparationStatus.Error ->
|
||||
ApkPrepareRowIconButton(
|
||||
icon = Icons.Default.Refresh,
|
||||
description = stringResource(
|
||||
R.string.prepare_apk_retry
|
||||
),
|
||||
onClick = {
|
||||
apkViewModel.onEvent(
|
||||
ApkUiEvent.PrepareRowClicked
|
||||
)
|
||||
},
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare Dialog
|
||||
if (apkUiState.showPrepareDialog) {
|
||||
val status = apkStatus
|
||||
val sizeMB: Int? = when (status) {
|
||||
is ApkPreparationStatus.NotDownloaded -> status.sizeMB
|
||||
is ApkPreparationStatus.UpdateAvailable -> status.newSizeMB
|
||||
else -> null
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = { apkViewModel.onEvent(ApkUiEvent.DismissPrepareDialog) },
|
||||
title = {
|
||||
Text(
|
||||
text = if (status is ApkPreparationStatus.UpdateAvailable) {
|
||||
stringResource(R.string.prepare_apk_update_dialog_title)
|
||||
} else {
|
||||
stringResource(R.string.prepare_apk_dialog_title)
|
||||
},
|
||||
text = stringResource(
|
||||
if (availableUpdate != null) {
|
||||
R.string.prepare_apk_update_dialog_title
|
||||
} else {
|
||||
R.string.prepare_apk_dialog_title
|
||||
}
|
||||
),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = if (status is ApkPreparationStatus.UpdateAvailable) {
|
||||
stringResource(R.string.prepare_apk_update_dialog_message, status.newVersion, status.currentVersion)
|
||||
} else if (sizeMB != null) {
|
||||
stringResource(R.string.prepare_apk_dialog_message, sizeMB)
|
||||
text = if (availableUpdate != null) {
|
||||
stringResource(
|
||||
R.string.prepare_apk_update_dialog_message,
|
||||
availableUpdate.version,
|
||||
availableUpdate.sizeMB
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.prepare_apk_dialog_message_unknown_size)
|
||||
stringResource(
|
||||
R.string.prepare_apk_dialog_message_unknown_size
|
||||
)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
@ -678,7 +945,11 @@ fun AboutSheet(
|
||||
containerColor = colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Delete")
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.prepare_apk_button_delete
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
@ -690,9 +961,9 @@ fun AboutSheet(
|
||||
)
|
||||
}
|
||||
|
||||
// Show sharing rows only when APK is ready
|
||||
val canShareAPK = apkStatus is ApkPreparationStatus.Ready ||
|
||||
apkStatus is ApkPreparationStatus.UpdateAvailable
|
||||
// A GitHub update is optional. Keep sharing visible while the
|
||||
// replacement downloads or while metadata refreshes.
|
||||
val canShareAPK = shareableApk != null
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = canShareAPK,
|
||||
|
||||
@ -1,16 +1,22 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.util.ApkDownloader
|
||||
import com.bitchat.android.util.AppVersion
|
||||
import com.bitchat.android.util.GitHubReleaseClient
|
||||
import com.bitchat.android.util.LatestReleaseProvider
|
||||
import com.bitchat.android.util.ShareableApkVariant
|
||||
import com.bitchat.android.util.UniversalApkManager
|
||||
import com.bitchat.android.util.WorkManagerApkDownloader
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@ -24,25 +30,61 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
sealed class ApkPreparationStatus {
|
||||
object Loading : ApkPreparationStatus()
|
||||
data class NotDownloaded(val sizeMB: Int?) : ApkPreparationStatus()
|
||||
object NotDownloaded : ApkPreparationStatus()
|
||||
data class Ready(
|
||||
val version: String,
|
||||
val sizeMB: Int,
|
||||
val source: UniversalApkManager.ApkSource,
|
||||
val variant: ShareableApkVariant
|
||||
) : ApkPreparationStatus()
|
||||
data class UpdateAvailable(
|
||||
val currentVersion: String,
|
||||
val newVersion: String,
|
||||
val newSizeMB: Int
|
||||
/** [phase] is what the operation is actually doing; only a transfer has a real percentage. */
|
||||
data class Downloading(
|
||||
val phase: ApkDownloader.DownloadPhase = ApkDownloader.DownloadPhase.SelectingSource,
|
||||
val shareableFallback: Ready? = null
|
||||
) : ApkPreparationStatus()
|
||||
object Downloading : ApkPreparationStatus()
|
||||
data class Resumable(val progressPercent: Int, val message: String) : ApkPreparationStatus()
|
||||
data class Error(val message: String) : ApkPreparationStatus()
|
||||
data class Resumable(
|
||||
val progressPercent: Int,
|
||||
val failure: ApkFailureMessage
|
||||
) : ApkPreparationStatus()
|
||||
data class Error(val failure: ApkFailureMessage) : ApkPreparationStatus()
|
||||
}
|
||||
|
||||
/** A localizable failure kept as data until the UI or a one-shot effect renders it. */
|
||||
data class ApkFailureMessage(
|
||||
@StringRes val messageRes: Int,
|
||||
val messageArgs: List<String> = emptyList()
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolves a failure defensively. The reason and its arguments cross the WorkManager boundary
|
||||
* independently, so an argument list that does not match the format string is possible; a row
|
||||
* showing generic text beats one that throws while formatting.
|
||||
*/
|
||||
internal fun Context.resolveApkFailureMessage(failure: ApkFailureMessage): String {
|
||||
return runCatching {
|
||||
getString(
|
||||
failure.messageRes,
|
||||
*failure.messageArgs.toTypedArray()
|
||||
)
|
||||
}.getOrElse {
|
||||
getString(R.string.prepare_apk_error_generic)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ApkReleaseStatus {
|
||||
object Unknown : ApkReleaseStatus()
|
||||
object Checking : ApkReleaseStatus()
|
||||
data class Known(
|
||||
val version: String,
|
||||
val sizeMB: Int,
|
||||
val isNewerThanSharedApk: Boolean,
|
||||
val fromStaleCache: Boolean
|
||||
) : ApkReleaseStatus()
|
||||
}
|
||||
|
||||
data class ApkUiState(
|
||||
val apkStatus: ApkPreparationStatus = ApkPreparationStatus.Loading,
|
||||
val releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown,
|
||||
val downloadProgress: Int = 0,
|
||||
val showPrepareDialog: Boolean = false,
|
||||
val showDeleteDialog: Boolean = false,
|
||||
@ -67,6 +109,36 @@ sealed class ApkUiEvent {
|
||||
object CancelDownload : ApkUiEvent()
|
||||
}
|
||||
|
||||
// --- Row tap ---
|
||||
|
||||
/** What tapping the body of the prepare row does. */
|
||||
internal enum class PrepareRowTapAction {
|
||||
OpenPrepareDialog,
|
||||
StartDownload
|
||||
}
|
||||
|
||||
/**
|
||||
* What a tap on the prepare row means for [status], or null when the row has nothing to offer.
|
||||
*
|
||||
* The trailing controls are icon-only, so the row body is the discoverable half of every action
|
||||
* and has to stay in step with them. Deriving both the tap handler and the row's `enabled` flag
|
||||
* from this one function keeps the row from looking clickable while doing nothing.
|
||||
*/
|
||||
internal fun prepareRowTapAction(
|
||||
status: ApkPreparationStatus,
|
||||
releaseStatus: ApkReleaseStatus = ApkReleaseStatus.Unknown
|
||||
): PrepareRowTapAction? = when {
|
||||
status is ApkPreparationStatus.NotDownloaded -> PrepareRowTapAction.OpenPrepareDialog
|
||||
// Consent was already given for these; resuming straight away avoids a redundant prompt.
|
||||
status is ApkPreparationStatus.Resumable -> PrepareRowTapAction.StartDownload
|
||||
status is ApkPreparationStatus.Error -> PrepareRowTapAction.StartDownload
|
||||
status is ApkPreparationStatus.Ready &&
|
||||
(status.source == UniversalApkManager.ApkSource.INSTALLED ||
|
||||
(releaseStatus as? ApkReleaseStatus.Known)?.isNewerThanSharedApk == true) ->
|
||||
PrepareRowTapAction.OpenPrepareDialog
|
||||
else -> null
|
||||
}
|
||||
|
||||
// --- Effects (ViewModel → UI, one-shot) ---
|
||||
|
||||
sealed class ApkUiEffect {
|
||||
@ -79,21 +151,32 @@ sealed class ApkUiEffect {
|
||||
* ViewModel for APK download/status/share logic following MVI pattern.
|
||||
* UI sends [ApkUiEvent], observes [ApkUiState], and collects [ApkUiEffect].
|
||||
*/
|
||||
class ApkDownloadViewModel(application: Application) : AndroidViewModel(application) {
|
||||
class ApkDownloadViewModel internal constructor(
|
||||
application: Application,
|
||||
private val apkManager: UniversalApkManager,
|
||||
private val downloader: ApkDownloader,
|
||||
private val latestReleaseProvider: LatestReleaseProvider
|
||||
) : AndroidViewModel(application) {
|
||||
|
||||
constructor(application: Application) : this(
|
||||
application = application,
|
||||
apkManager = UniversalApkManager(application),
|
||||
downloader = WorkManagerApkDownloader(application),
|
||||
latestReleaseProvider = GitHubReleaseClient(application)
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ApkDownloadVM"
|
||||
}
|
||||
|
||||
private val apkManager = UniversalApkManager(application)
|
||||
private val downloader: ApkDownloader = WorkManagerApkDownloader(application)
|
||||
|
||||
private val _state = MutableStateFlow(ApkUiState())
|
||||
val state: StateFlow<ApkUiState> = _state.asStateFlow()
|
||||
|
||||
private val _effect = Channel<ApkUiEffect>(Channel.BUFFERED)
|
||||
val effect = _effect.receiveAsFlow()
|
||||
|
||||
private var metadataRefreshJob: Job? = null
|
||||
|
||||
init {
|
||||
observeDownloader()
|
||||
}
|
||||
@ -117,16 +200,16 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
|
||||
}
|
||||
|
||||
private fun onPrepareRowClicked() {
|
||||
when (_state.value.apkStatus) {
|
||||
is ApkPreparationStatus.NotDownloaded,
|
||||
is ApkPreparationStatus.UpdateAvailable,
|
||||
is ApkPreparationStatus.Error -> {
|
||||
when (
|
||||
prepareRowTapAction(
|
||||
_state.value.apkStatus,
|
||||
_state.value.releaseStatus
|
||||
)
|
||||
) {
|
||||
PrepareRowTapAction.OpenPrepareDialog ->
|
||||
_state.update { it.copy(showPrepareDialog = true) }
|
||||
}
|
||||
is ApkPreparationStatus.Resumable -> {
|
||||
startDownload()
|
||||
}
|
||||
else -> {}
|
||||
PrepareRowTapAction.StartDownload -> startDownload()
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,8 +220,10 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
|
||||
|
||||
private fun onDownloadUniversalClicked() {
|
||||
val status = _state.value.apkStatus
|
||||
val hasUpdate = (_state.value.releaseStatus as? ApkReleaseStatus.Known)
|
||||
?.isNewerThanSharedApk == true
|
||||
if (status is ApkPreparationStatus.Ready &&
|
||||
status.variant == ShareableApkVariant.ARM64
|
||||
(status.source == UniversalApkManager.ApkSource.INSTALLED || hasUpdate)
|
||||
) {
|
||||
_state.update { it.copy(showPrepareDialog = true) }
|
||||
}
|
||||
@ -193,14 +278,31 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
|
||||
|
||||
private fun onCancelDownload() {
|
||||
downloader.cancelDownload()
|
||||
checkStatus()
|
||||
|
||||
val fallback = (_state.value.apkStatus as? ApkPreparationStatus.Downloading)
|
||||
?.shareableFallback
|
||||
_state.update {
|
||||
it.copy(
|
||||
apkStatus = fallback ?: ApkPreparationStatus.Loading,
|
||||
downloadProgress = 0
|
||||
)
|
||||
}
|
||||
if (fallback == null) checkStatus()
|
||||
}
|
||||
|
||||
private fun startDownload() {
|
||||
val current = _state.value.apkStatus
|
||||
val fallback = when (current) {
|
||||
is ApkPreparationStatus.Ready -> current
|
||||
is ApkPreparationStatus.Downloading -> current.shareableFallback
|
||||
else -> null
|
||||
}
|
||||
val partial = apkManager.getPartialDownloadProgress()
|
||||
_state.update {
|
||||
it.copy(
|
||||
apkStatus = ApkPreparationStatus.Downloading,
|
||||
apkStatus = ApkPreparationStatus.Downloading(
|
||||
shareableFallback = fallback
|
||||
),
|
||||
downloadProgress = partial ?: 0
|
||||
)
|
||||
}
|
||||
@ -209,21 +311,68 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
|
||||
|
||||
private fun checkStatus() {
|
||||
viewModelScope.launch {
|
||||
// WorkManager is the source of truth for active work. A queued or
|
||||
// newly started job legitimately has no partial file yet, so never
|
||||
// infer that it is orphaned from cache contents.
|
||||
if (_state.value.apkStatus is ApkPreparationStatus.Downloading) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
val resolvedStatus = resolveApkStatus()
|
||||
_state.update { current ->
|
||||
if (current.apkStatus is ApkPreparationStatus.Downloading) {
|
||||
current
|
||||
} else {
|
||||
current.copy(apkStatus = resolvedStatus)
|
||||
// WorkManager is the source of truth for active work. A queued or newly started
|
||||
// job legitimately has no partial file yet, so never infer that it is orphaned
|
||||
// from cache contents, and never let a resolved status overwrite it - the user
|
||||
// may have started a download while the local artifact was being inspected.
|
||||
when (val active = current.apkStatus) {
|
||||
is ApkPreparationStatus.Downloading ->
|
||||
// Active work still adopts a local artifact it was created without. A
|
||||
// ViewModel restored onto a running download starts from Loading, so the
|
||||
// observer had no Ready to carry into shareableFallback, and an installed
|
||||
// APK - with both sharing actions - would stay hidden for the whole
|
||||
// transfer. Deciding here covers the observer arriving before this runs
|
||||
// and during the resolve above, which are different orderings.
|
||||
if (active.shareableFallback == null) {
|
||||
current.copy(
|
||||
apkStatus = active.copy(
|
||||
shareableFallback = shareableReady(resolvedStatus)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
else -> current.copy(
|
||||
apkStatus = resolvedStatus,
|
||||
downloadProgress = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
// Metadata is skipped while work is active, as it was before: an in-flight download
|
||||
// has no use for a freshness check and the API budget is scarce.
|
||||
if (_state.value.apkStatus is ApkPreparationStatus.Downloading) return@launch
|
||||
// Local availability is resolved and published before this independent network task
|
||||
// starts. Metadata can add a freshness warning, but can never hide sharing.
|
||||
refreshReleaseMetadata()
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshReleaseMetadata() {
|
||||
if (metadataRefreshJob?.isActive == true) return
|
||||
metadataRefreshJob = viewModelScope.launch {
|
||||
_state.update { it.copy(releaseStatus = ApkReleaseStatus.Checking) }
|
||||
latestReleaseProvider.latestRelease()
|
||||
.onSuccess { snapshot ->
|
||||
val shared = shareableReady(_state.value.apkStatus)
|
||||
_state.update {
|
||||
it.copy(
|
||||
releaseStatus = ApkReleaseStatus.Known(
|
||||
version = snapshot.release.versionName,
|
||||
sizeMB = (snapshot.release.universalApkSize / 1024 / 1024).toInt(),
|
||||
isNewerThanSharedApk = shared?.let { ready ->
|
||||
AppVersion.isNewer(ready.version, snapshot.release.versionName)
|
||||
} ?: false,
|
||||
fromStaleCache = snapshot.isStale
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
// Metadata is an optional enhancement. Keep the locally resolved APK state.
|
||||
_state.update { it.copy(releaseStatus = ApkReleaseStatus.Unknown) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -232,12 +381,28 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
|
||||
downloader.downloadState.collect { downloadState ->
|
||||
when (downloadState) {
|
||||
is ApkDownloader.DownloadState.Idle -> {
|
||||
// Don't overwrite — status set by checkStatus()
|
||||
val downloading = _state.value.apkStatus as? ApkPreparationStatus.Downloading
|
||||
if (downloading != null) {
|
||||
val fallback = downloading.shareableFallback
|
||||
_state.update {
|
||||
it.copy(
|
||||
apkStatus = fallback ?: ApkPreparationStatus.Loading,
|
||||
downloadProgress = 0
|
||||
)
|
||||
}
|
||||
if (fallback == null) checkStatus()
|
||||
}
|
||||
}
|
||||
is ApkDownloader.DownloadState.Downloading -> {
|
||||
_state.update {
|
||||
val fallback = (it.apkStatus as? ApkPreparationStatus.Downloading)
|
||||
?.shareableFallback
|
||||
?: (it.apkStatus as? ApkPreparationStatus.Ready)
|
||||
it.copy(
|
||||
apkStatus = ApkPreparationStatus.Downloading,
|
||||
apkStatus = ApkPreparationStatus.Downloading(
|
||||
phase = downloadState.phase,
|
||||
shareableFallback = fallback
|
||||
),
|
||||
downloadProgress = downloadState.progressPercent
|
||||
)
|
||||
}
|
||||
@ -245,48 +410,53 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
|
||||
is ApkDownloader.DownloadState.Success -> {
|
||||
val info = apkManager.getCachedApkInfo()
|
||||
_state.update {
|
||||
val ready = ApkPreparationStatus.Ready(
|
||||
version = info?.version ?: downloadState.version,
|
||||
sizeMB = info?.let { cached ->
|
||||
(cached.size / 1024 / 1024).toInt()
|
||||
} ?: downloadState.sizeMB,
|
||||
source = info?.source ?: UniversalApkManager.ApkSource.DOWNLOADED,
|
||||
variant = info?.variant ?: ShareableApkVariant.UNIVERSAL
|
||||
)
|
||||
it.copy(
|
||||
apkStatus = ApkPreparationStatus.Ready(
|
||||
version = info?.version ?: downloadState.version,
|
||||
sizeMB = info?.let { cached ->
|
||||
(cached.size / 1024 / 1024).toInt()
|
||||
} ?: downloadState.sizeMB,
|
||||
source = info?.source ?: UniversalApkManager.ApkSource.GITHUB,
|
||||
variant = info?.variant ?: ShareableApkVariant.UNIVERSAL
|
||||
),
|
||||
apkStatus = ready,
|
||||
releaseStatus = releaseStatusFor(ready, it.releaseStatus),
|
||||
downloadProgress = 100
|
||||
)
|
||||
}
|
||||
}
|
||||
is ApkDownloader.DownloadState.Failed -> {
|
||||
val localArm64 = apkManager.getCachedApkInfo()
|
||||
?.takeIf { it.variant == ShareableApkVariant.ARM64 }
|
||||
if (localArm64 != null) {
|
||||
val failure = downloadState.toFailureMessage()
|
||||
val fallback = (_state.value.apkStatus as? ApkPreparationStatus.Downloading)
|
||||
?.shareableFallback
|
||||
?: apkManager.getCachedApkInfo()?.toReady()
|
||||
if (fallback != null) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
apkStatus = ApkPreparationStatus.Ready(
|
||||
version = localArm64.version,
|
||||
sizeMB = (localArm64.size / 1024 / 1024).toInt(),
|
||||
source = localArm64.source,
|
||||
variant = localArm64.variant
|
||||
)
|
||||
apkStatus = fallback,
|
||||
releaseStatus = releaseStatusFor(fallback, it.releaseStatus),
|
||||
downloadProgress = 0
|
||||
)
|
||||
}
|
||||
_effect.send(ApkUiEffect.ShowToast(downloadState.message))
|
||||
_effect.send(
|
||||
ApkUiEffect.ShowToast(
|
||||
getApplication<Application>().resolveApkFailureMessage(
|
||||
failure
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
_state.update {
|
||||
if (downloadState.resumablePercent != null) {
|
||||
it.copy(
|
||||
apkStatus = ApkPreparationStatus.Resumable(
|
||||
progressPercent = downloadState.resumablePercent,
|
||||
message = downloadState.message
|
||||
failure = failure
|
||||
),
|
||||
downloadProgress = downloadState.resumablePercent
|
||||
)
|
||||
} else {
|
||||
it.copy(
|
||||
apkStatus = ApkPreparationStatus.Error(downloadState.message)
|
||||
)
|
||||
it.copy(apkStatus = ApkPreparationStatus.Error(failure))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -306,71 +476,55 @@ class ApkDownloadViewModel(application: Application) : AndroidViewModel(applicat
|
||||
return getApplication<Application>().getString(resId)
|
||||
}
|
||||
|
||||
private fun ApkDownloader.DownloadState.Failed.toFailureMessage() = ApkFailureMessage(
|
||||
messageRes = reason.messageRes,
|
||||
messageArgs = messageArgs
|
||||
)
|
||||
|
||||
private fun shareableReady(status: ApkPreparationStatus): ApkPreparationStatus.Ready? =
|
||||
when (status) {
|
||||
is ApkPreparationStatus.Ready -> status
|
||||
is ApkPreparationStatus.Downloading -> status.shareableFallback
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun releaseStatusFor(
|
||||
ready: ApkPreparationStatus.Ready,
|
||||
releaseStatus: ApkReleaseStatus
|
||||
): ApkReleaseStatus = (releaseStatus as? ApkReleaseStatus.Known)?.let {
|
||||
it.copy(isNewerThanSharedApk = AppVersion.isNewer(ready.version, it.version))
|
||||
} ?: releaseStatus
|
||||
|
||||
private fun UniversalApkManager.ApkInfo.toReady() = ApkPreparationStatus.Ready(
|
||||
version = version,
|
||||
sizeMB = (size / 1024 / 1024).toInt(),
|
||||
source = source,
|
||||
variant = variant
|
||||
)
|
||||
|
||||
private suspend fun resolveApkStatus(): ApkPreparationStatus = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val updateStatus = apkManager.checkForUpdate()
|
||||
when (updateStatus) {
|
||||
is UniversalApkManager.UpdateStatus.NotDownloaded -> {
|
||||
val partial = apkManager.getPartialDownloadProgress()
|
||||
if (partial != null) {
|
||||
ApkPreparationStatus.Resumable(
|
||||
progressPercent = partial,
|
||||
message = getString(R.string.prepare_apk_download_interrupted)
|
||||
val info = apkManager.prepareLocalApkInfo()
|
||||
if (info != null) {
|
||||
info.toReady()
|
||||
} else {
|
||||
val partial = apkManager.getPartialDownloadProgress()
|
||||
if (partial != null) {
|
||||
ApkPreparationStatus.Resumable(
|
||||
progressPercent = partial,
|
||||
failure = ApkFailureMessage(
|
||||
messageRes = R.string.prepare_apk_download_interrupted
|
||||
)
|
||||
} else {
|
||||
ApkPreparationStatus.NotDownloaded(
|
||||
sizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt()
|
||||
)
|
||||
}
|
||||
}
|
||||
is UniversalApkManager.UpdateStatus.UpToDate -> {
|
||||
val info = apkManager.getCachedApkInfo()
|
||||
if (info != null) {
|
||||
ApkPreparationStatus.Ready(
|
||||
version = info.version,
|
||||
sizeMB = (info.size / 1024 / 1024).toInt(),
|
||||
source = info.source,
|
||||
variant = info.variant
|
||||
)
|
||||
} else {
|
||||
ApkPreparationStatus.Error("Cached APK info not found")
|
||||
}
|
||||
}
|
||||
is UniversalApkManager.UpdateStatus.UpdateAvailable -> {
|
||||
ApkPreparationStatus.UpdateAvailable(
|
||||
currentVersion = updateStatus.currentVersion,
|
||||
newVersion = updateStatus.latestRelease.versionName,
|
||||
newSizeMB = (updateStatus.latestRelease.universalApkSize / 1024 / 1024).toInt()
|
||||
)
|
||||
}
|
||||
is UniversalApkManager.UpdateStatus.Error -> {
|
||||
// A cached artifact stays shareable even when the update
|
||||
// check fails or the release lags the installed version.
|
||||
val info = apkManager.getCachedApkInfo()
|
||||
if (info != null) {
|
||||
ApkPreparationStatus.Ready(
|
||||
version = info.version,
|
||||
sizeMB = (info.size / 1024 / 1024).toInt(),
|
||||
source = info.source,
|
||||
variant = info.variant
|
||||
)
|
||||
} else {
|
||||
val partial = apkManager.getPartialDownloadProgress()
|
||||
if (partial != null) {
|
||||
ApkPreparationStatus.Resumable(
|
||||
progressPercent = partial,
|
||||
message = getString(R.string.prepare_apk_download_interrupted)
|
||||
)
|
||||
} else {
|
||||
ApkPreparationStatus.Error(updateStatus.message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ApkPreparationStatus.NotDownloaded
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error checking APK status", e)
|
||||
// The exception text is English and often internal; log it, show a translated line.
|
||||
Log.e(TAG, "Error reading APK status", e)
|
||||
ApkPreparationStatus.Error(
|
||||
e.message ?: getString(R.string.prepare_apk_error_github)
|
||||
ApkFailureMessage(messageRes = R.string.share_apk_error)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,105 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The progress readout for the prepare-for-sharing row.
|
||||
*
|
||||
* This sits under the row's subtitle so the trailing slot is free to hold a single control. Which
|
||||
* of the three renderings applies is decided entirely by [status]; the caller does not choose.
|
||||
*
|
||||
* Determinate transfer/resume progress and indeterminate non-transfer phases use the stable
|
||||
* Material 3 progress API. The expressive wavy variant can be introduced independently later.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ApkDownloadProgressBar(
|
||||
status: ApkPreparationStatus,
|
||||
progressPercent: Int,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val barModifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 6.dp)
|
||||
|
||||
when {
|
||||
// Only the transfer knows a fraction. Elsewhere an indeterminate bar is honest about
|
||||
// having no measure, the same distinction the subtitle already draws.
|
||||
status is ApkPreparationStatus.Downloading &&
|
||||
status.phase.hasMeasurableProgress &&
|
||||
progressPercent > 0 ->
|
||||
LinearProgressIndicator(
|
||||
progress = { progressPercent.asProgressFraction() },
|
||||
modifier = barModifier
|
||||
)
|
||||
|
||||
status is ApkPreparationStatus.Downloading ->
|
||||
LinearProgressIndicator(modifier = barModifier)
|
||||
|
||||
status is ApkPreparationStatus.Resumable ->
|
||||
LinearProgressIndicator(
|
||||
progress = { status.progressPercent.asProgressFraction() },
|
||||
modifier = barModifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Percentages arrive from a worker across a process boundary, so they are not trusted to be 0..100. */
|
||||
private fun Int.asProgressFraction(): Float = (this / 100f).coerceIn(0f, 1f)
|
||||
|
||||
/**
|
||||
* The single trailing control on the prepare row.
|
||||
*
|
||||
* Every status renders exactly one of these at the same width, so the text column beside it keeps
|
||||
* its measure and stops re-wrapping each time the status changes.
|
||||
*
|
||||
* These buttons carry no visible label, which makes [description] load-bearing rather than
|
||||
* decorative: it is both the TalkBack announcement and the long-press tooltip for a sighted user
|
||||
* who does not recognise the glyph.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun ApkPrepareRowIconButton(
|
||||
icon: ImageVector,
|
||||
description: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
) {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
|
||||
tooltip = { PlainTooltip { Text(description) } },
|
||||
state = rememberTooltipState(),
|
||||
modifier = modifier
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = description,
|
||||
tint = if (enabled) tint else tint.copy(alpha = 0.38f),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
287
app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt
Normal file
287
app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt
Normal file
@ -0,0 +1,287 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.google.gson.JsonParser
|
||||
import java.net.URI
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.math.BigDecimal
|
||||
import java.util.Base64
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Bounded, display-only Cashu token decoder. Tokens are bearer instruments, so
|
||||
* this class never contacts a mint or attempts to hold/redeem funds.
|
||||
*/
|
||||
object CashuTokenDecoder {
|
||||
const val MAX_TOKEN_LENGTH = 60_000
|
||||
private const val MAX_AMOUNT = 2_100_000_000_000_000L
|
||||
|
||||
data class TokenInfo(
|
||||
val version: Char,
|
||||
val amount: Long?,
|
||||
val unit: String?,
|
||||
val mintHost: String?,
|
||||
val memo: String?
|
||||
) {
|
||||
val displayAmount: String?
|
||||
get() = amount?.let { value ->
|
||||
val displayUnit = unit ?: "sat"
|
||||
val minorDigits = minorUnitDigits(displayUnit)
|
||||
val formatted = if (minorDigits == null || minorDigits == 0) {
|
||||
value.toString()
|
||||
} else {
|
||||
BigDecimal.valueOf(value, minorDigits).setScale(minorDigits).toPlainString()
|
||||
}
|
||||
"$formatted $displayUnit"
|
||||
}
|
||||
}
|
||||
|
||||
fun bareToken(raw: String): String? {
|
||||
var token = raw.trim()
|
||||
if ('%' in token) token = percentDecode(token) ?: return null
|
||||
token = when {
|
||||
token.startsWith("cashu://", ignoreCase = true) -> token.substring(8)
|
||||
token.startsWith("cashu:", ignoreCase = true) -> token.substring(6)
|
||||
else -> token
|
||||
}
|
||||
if (token.length !in 12..MAX_TOKEN_LENGTH) return null
|
||||
if (!token.startsWith("cashuA") && !token.startsWith("cashuB")) return null
|
||||
if (token.any { !it.isLetterOrDigit() && it !in "-_+/=" }) return null
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissive decoding is suitable for display: unsupported but plausible
|
||||
* v4 CBOR still gets a generic chip. Strict decoding is required before
|
||||
* sending and accepts only a fully parsed token with a positive amount.
|
||||
*/
|
||||
fun decode(raw: String, strict: Boolean = false): TokenInfo? {
|
||||
val token = bareToken(raw) ?: return null
|
||||
val payload = decodeBase64Url(token.substring(6)) ?: return null
|
||||
if (payload.isEmpty()) return null
|
||||
val info = when (token[5]) {
|
||||
'A' -> decodeV3(payload)
|
||||
'B' -> decodeV4(payload) ?: if (strict) null else TokenInfo('B', null, null, null, null)
|
||||
else -> null
|
||||
} ?: return null
|
||||
return if (!strict || (info.amount != null && info.amount > 0)) info else null
|
||||
}
|
||||
|
||||
fun extractTokens(text: String, max: Int = 3): List<String> {
|
||||
if (text.isEmpty() || max <= 0) return emptyList()
|
||||
val matches = TOKEN_REGEX.findAll(text)
|
||||
val result = LinkedHashSet<String>()
|
||||
for (match in matches) {
|
||||
bareToken(match.value)?.let(result::add)
|
||||
if (result.size == max) break
|
||||
}
|
||||
return result.toList()
|
||||
}
|
||||
|
||||
fun walletUri(token: String): String? = bareToken(token)?.let { "cashu:${encodeUriComponent(it)}" }
|
||||
|
||||
fun webRedeemUri(token: String): String? =
|
||||
bareToken(token)?.let { "https://redeem.cashu.me/?token=${encodeUriComponent(it)}" }
|
||||
|
||||
private fun encodeUriComponent(value: String): String =
|
||||
URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20")
|
||||
|
||||
/** Percent-decodes URI input without URLDecoder's form-specific '+' → space conversion. */
|
||||
private fun percentDecode(value: String): String? {
|
||||
val output = StringBuilder(value.length)
|
||||
var index = 0
|
||||
while (index < value.length) {
|
||||
if (value[index] != '%') {
|
||||
output.append(value[index++])
|
||||
continue
|
||||
}
|
||||
val bytes = ArrayList<Byte>()
|
||||
while (index < value.length && value[index] == '%') {
|
||||
if (index + 2 >= value.length) return null
|
||||
val byte = value.substring(index + 1, index + 3).toIntOrNull(16) ?: return null
|
||||
bytes += byte.toByte()
|
||||
index += 3
|
||||
}
|
||||
output.append(String(bytes.toByteArray(), StandardCharsets.UTF_8))
|
||||
}
|
||||
return output.toString()
|
||||
}
|
||||
|
||||
private fun decodeBase64Url(input: String): ByteArray? {
|
||||
val normalized = input.replace('-', '+').replace('_', '/').trimEnd('=')
|
||||
if (normalized.length % 4 == 1) return null
|
||||
val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4)
|
||||
return runCatching { Base64.getDecoder().decode(padded) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun decodeV3(payload: ByteArray): TokenInfo? = runCatching {
|
||||
val root = JsonParser.parseString(String(payload, StandardCharsets.UTF_8)).asJsonObject
|
||||
val entries = root.getAsJsonArray("token")?.takeIf { it.size() > 0 } ?: return null
|
||||
var total = 0L
|
||||
var sawAmount = false
|
||||
var mintHost: String? = null
|
||||
for (entryElement in entries) {
|
||||
val entry = entryElement.takeIf { it.isJsonObject }?.asJsonObject ?: continue
|
||||
if (mintHost == null) mintHost = sanitizeHost(entry.get("mint")?.takeIf { it.isJsonPrimitive }?.asString)
|
||||
val proofs = entry.getAsJsonArray("proofs") ?: continue
|
||||
for (proofElement in proofs) {
|
||||
val amountElement = proofElement.takeIf { it.isJsonObject }?.asJsonObject?.get("amount") ?: continue
|
||||
if (!amountElement.isJsonPrimitive || !amountElement.asJsonPrimitive.isNumber) continue
|
||||
val value = runCatching { amountElement.asBigDecimal.longValueExact() }.getOrNull() ?: continue
|
||||
if (value <= 0 || value > MAX_AMOUNT) continue
|
||||
if (total > MAX_AMOUNT - value) return null
|
||||
total += value
|
||||
sawAmount = true
|
||||
}
|
||||
}
|
||||
TokenInfo(
|
||||
version = 'A',
|
||||
amount = total.takeIf { sawAmount },
|
||||
unit = sanitizeUnit(root.get("unit")?.takeIf { it.isJsonPrimitive }?.asString),
|
||||
mintHost = mintHost,
|
||||
memo = sanitizeMemo(root.get("memo")?.takeIf { it.isJsonPrimitive }?.asString)
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
private fun decodeV4(payload: ByteArray): TokenInfo? {
|
||||
val root = CborReader(payload).parseComplete() as? CborValue.MapValue ?: return null
|
||||
var total = 0L
|
||||
var sawAmount = false
|
||||
var mintHost: String? = null
|
||||
var unit: String? = null
|
||||
var memo: String? = null
|
||||
for ((key, value) in root.pairs) {
|
||||
when ((key as? CborValue.Text)?.value) {
|
||||
"m" -> mintHost = sanitizeHost((value as? CborValue.Text)?.value)
|
||||
"u" -> unit = sanitizeUnit((value as? CborValue.Text)?.value)
|
||||
"d" -> memo = sanitizeMemo((value as? CborValue.Text)?.value)
|
||||
"t" -> for (group in (value as? CborValue.ArrayValue)?.values.orEmpty()) {
|
||||
for ((groupKey, groupValue) in (group as? CborValue.MapValue)?.pairs.orEmpty()) {
|
||||
if ((groupKey as? CborValue.Text)?.value != "p") continue
|
||||
for (proof in (groupValue as? CborValue.ArrayValue)?.values.orEmpty()) {
|
||||
for ((proofKey, proofValue) in (proof as? CborValue.MapValue)?.pairs.orEmpty()) {
|
||||
if ((proofKey as? CborValue.Text)?.value != "a") continue
|
||||
val amount = (proofValue as? CborValue.Unsigned)?.value ?: continue
|
||||
if (amount == 0L || amount > MAX_AMOUNT) continue
|
||||
if (total > MAX_AMOUNT - amount) return null
|
||||
total += amount
|
||||
sawAmount = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return TokenInfo('B', total.takeIf { sawAmount }, unit, mintHost, memo)
|
||||
}
|
||||
|
||||
private fun sanitizeHost(value: String?): String? = value
|
||||
?.takeIf { it.length <= 512 }
|
||||
?.let { runCatching { URI(it).host }.getOrNull() }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.lowercase()
|
||||
?.take(48)
|
||||
|
||||
private fun sanitizeUnit(value: String?): String? =
|
||||
value?.takeIf { it.isNotEmpty() && it.length <= 12 && it.all(Char::isLetterOrDigit) }
|
||||
|
||||
private fun sanitizeMemo(value: String?): String? {
|
||||
if (value == null || value.length > 512) return null
|
||||
return value.filterNot(Char::isISOControl).trim().take(80).takeIf(String::isNotEmpty)
|
||||
}
|
||||
|
||||
/** ISO-4217 values use their currency's minor unit; custom units stay integer-denominated. */
|
||||
private fun minorUnitDigits(unit: String): Int? {
|
||||
return runCatching {
|
||||
Currency.getInstance(unit.uppercase(Locale.ROOT)).defaultFractionDigits
|
||||
}.getOrNull()?.takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
private val TOKEN_REGEX = Regex("""(?i:cashu:(?://)?)?cashu[AB][A-Za-z0-9_+/%=-]{6,}""")
|
||||
}
|
||||
|
||||
private sealed interface CborValue {
|
||||
data class Unsigned(val value: Long) : CborValue
|
||||
data class Text(val value: String) : CborValue
|
||||
data class ArrayValue(val values: List<CborValue>) : CborValue
|
||||
data class MapValue(val pairs: List<Pair<CborValue, CborValue>>) : CborValue
|
||||
data object Opaque : CborValue
|
||||
}
|
||||
|
||||
private class CborReader(private val bytes: ByteArray) {
|
||||
private var index = 0
|
||||
private var itemBudget = 50_000
|
||||
|
||||
fun parseComplete(): CborValue? {
|
||||
val value = parseValue(0) ?: return null
|
||||
return value.takeIf { index == bytes.size }
|
||||
}
|
||||
|
||||
private fun parseValue(depth: Int): CborValue? {
|
||||
if (depth >= 16 || itemBudget-- <= 0) return null
|
||||
val (major, argument) = readHead() ?: return null
|
||||
return when (major) {
|
||||
0 -> CborValue.Unsigned(argument.takeIf { it <= Long.MAX_VALUE }?.toLong() ?: return null)
|
||||
1 -> CborValue.Opaque
|
||||
2 -> if (readBytes(argument) != null) CborValue.Opaque else null
|
||||
3 -> readBytes(argument)?.toString(StandardCharsets.UTF_8)?.let(CborValue::Text)
|
||||
4 -> parseContainer(argument, depth) { CborValue.ArrayValue(it) }
|
||||
5 -> {
|
||||
if (argument > 10_000 || argument > itemBudget / 2) return null
|
||||
val pairs = ArrayList<Pair<CborValue, CborValue>>(argument.coerceAtMost(64).toInt())
|
||||
repeat(argument.toInt()) {
|
||||
pairs += (parseValue(depth + 1) ?: return null) to (parseValue(depth + 1) ?: return null)
|
||||
}
|
||||
CborValue.MapValue(pairs)
|
||||
}
|
||||
6 -> parseValue(depth + 1)
|
||||
7 -> CborValue.Opaque
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseContainer(
|
||||
count: Long,
|
||||
depth: Int,
|
||||
wrap: (List<CborValue>) -> CborValue
|
||||
): CborValue? {
|
||||
if (count > 10_000 || count > itemBudget) return null
|
||||
val values = ArrayList<CborValue>(count.coerceAtMost(64).toInt())
|
||||
repeat(count.toInt()) { values += parseValue(depth + 1) ?: return null }
|
||||
return wrap(values)
|
||||
}
|
||||
|
||||
private fun readHead(): Pair<Int, Long>? {
|
||||
if (index >= bytes.size) return null
|
||||
val head = bytes[index++].toInt() and 0xff
|
||||
val major = head ushr 5
|
||||
val info = head and 0x1f
|
||||
val argument = when (info) {
|
||||
in 0..23 -> info.toLong()
|
||||
24 -> readUInt(1)
|
||||
25 -> readUInt(2)
|
||||
26 -> readUInt(4)
|
||||
27 -> readUInt(8)
|
||||
else -> null
|
||||
} ?: return null
|
||||
return major to argument
|
||||
}
|
||||
|
||||
private fun readUInt(width: Int): Long? {
|
||||
if (bytes.size - index < width) return null
|
||||
var value = 0L
|
||||
repeat(width) {
|
||||
val next = bytes[index++].toLong() and 0xff
|
||||
if (value > (Long.MAX_VALUE - next) ushr 8) return null
|
||||
value = (value shl 8) or next
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private fun readBytes(count: Long): ByteArray? {
|
||||
if (count < 0 || count > bytes.size - index) return null
|
||||
val end = index + count.toInt()
|
||||
return bytes.copyOfRange(index, end).also { index = end }
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.MessageDigest
|
||||
@ -16,7 +17,8 @@ class ChannelManager(
|
||||
private val state: ChatState,
|
||||
private val messageManager: MessageManager,
|
||||
private val dataManager: DataManager,
|
||||
private val coroutineScope: CoroutineScope
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val onSwitchToMeshLocation: () -> Unit = {}
|
||||
) {
|
||||
|
||||
// Channel encryption and security
|
||||
@ -109,6 +111,8 @@ class ChannelManager(
|
||||
fun switchToChannel(channel: String?) {
|
||||
state.setCurrentChannel(channel)
|
||||
state.setSelectedPrivateChatPeer(null)
|
||||
state.setSelectedLocationChannel(ChannelID.Mesh)
|
||||
onSwitchToMeshLocation()
|
||||
|
||||
// Clear unread count
|
||||
channel?.let { ch ->
|
||||
|
||||
@ -40,6 +40,7 @@ import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@ -83,6 +84,28 @@ private val HeaderTextSize = 17.sp
|
||||
/** Minimum tap target for every interactive element in the header. */
|
||||
private val HeaderTapTarget = 44.dp
|
||||
|
||||
internal enum class HeaderCrowdingMode {
|
||||
Full,
|
||||
HideJoinedChannelCount,
|
||||
IconOnlyLocationChannel,
|
||||
}
|
||||
|
||||
internal fun headerCrowdingMode(availableWidth: Dp): HeaderCrowdingMode = when {
|
||||
availableWidth < 360.dp -> HeaderCrowdingMode.IconOnlyLocationChannel
|
||||
availableWidth < 400.dp -> HeaderCrowdingMode.HideJoinedChannelCount
|
||||
else -> HeaderCrowdingMode.Full
|
||||
}
|
||||
|
||||
internal fun locationChannelContentDescription(
|
||||
actionDescription: String,
|
||||
channelLabel: String,
|
||||
showLabel: Boolean,
|
||||
): String = if (showLabel) {
|
||||
actionDescription
|
||||
} else {
|
||||
"$channelLabel. $actionDescription"
|
||||
}
|
||||
|
||||
/** Corner radius for the header's tappable label+icon clusters. */
|
||||
private val HeaderClusterShape = RoundedCornerShape(8.dp)
|
||||
|
||||
@ -475,6 +498,19 @@ fun ConversationHeaderAction(
|
||||
content = content
|
||||
)
|
||||
|
||||
/** A read-only status slot matching the footprint of [ConversationHeaderAction]. */
|
||||
@Composable
|
||||
fun ConversationHeaderStatus(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.size(HeaderTapTarget),
|
||||
contentAlignment = Alignment.Center,
|
||||
content = { content() }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NicknameEditor(
|
||||
value: String,
|
||||
@ -518,6 +554,7 @@ fun NicknameEditor(
|
||||
}
|
||||
),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.widthIn(max = 150.dp)
|
||||
.horizontalScroll(scrollState)
|
||||
)
|
||||
@ -533,7 +570,8 @@ fun PeerCounter(
|
||||
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
||||
geohashPeople: List<GeoPerson>,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
showJoinedChannelCount: Boolean = true
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
@ -590,7 +628,7 @@ fun PeerCounter(
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
if (joinedChannels.isNotEmpty()) {
|
||||
if (showJoinedChannelCount && joinedChannels.isNotEmpty()) {
|
||||
AnimatedCount(
|
||||
count = joinedChannels.size,
|
||||
prefix = stringResource(R.string.channel_count_prefix),
|
||||
@ -690,22 +728,17 @@ private fun MainHeader(
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ChatHeaderHeight)
|
||||
.padding(start = HeaderInsetStart, end = HeaderInsetEnd),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// MARK: - Identity cluster.
|
||||
//
|
||||
// Weighted so it yields space to the status cluster rather than pushing it off screen.
|
||||
// Compose measures unweighted children first, so the icons on the right always get the
|
||||
// width they need and a long nickname simply scrolls within what is left.
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val crowdingMode = headerCrowdingMode(maxWidth)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ChatHeaderHeight)
|
||||
.padding(start = HeaderInsetStart, end = HeaderInsetEnd),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Keep the brand and trailing actions fixed. Only the nickname yields under pressure.
|
||||
BitChatBrandButton(
|
||||
onClick = onTitleClick,
|
||||
onTripleClick = onTripleTitleClick,
|
||||
@ -713,11 +746,13 @@ private fun MainHeader(
|
||||
modifier = Modifier.size(HeaderTapTarget),
|
||||
)
|
||||
|
||||
// Nudge toward the brand glyph: the 44.dp tap target leaves more optical gap than
|
||||
// spacing between the mark and the path label.
|
||||
// Nudge toward the brand glyph: the 44.dp tap target leaves more optical gap than the
|
||||
// spacing between the mark and path label.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.offset(x = (-6).dp)
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.offset(x = (-6).dp)
|
||||
) {
|
||||
Text(
|
||||
text = "/",
|
||||
@ -731,66 +766,58 @@ private fun MainHeader(
|
||||
|
||||
NicknameEditor(
|
||||
value = nickname,
|
||||
onValueChange = onNicknameChange
|
||||
onValueChange = onNicknameChange,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status cluster.
|
||||
//
|
||||
// Order, left to right: unread DMs, notes, channel, people.
|
||||
// Tor health is read from the location channel / notes icon colour rather than a
|
||||
// dedicated status dot.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// Tight, because every child below is its own >=44.dp tap target.
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
// Unread private messages badge (click to open most recent DM)
|
||||
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
||||
HeaderIconButton(
|
||||
onClick = { viewModel.openLatestUnreadPrivateChat() },
|
||||
contentDescription = stringResource(R.string.cd_unread_private_messages)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_spec_envelope),
|
||||
contentDescription = stringResource(R.string.cd_unread_private_messages),
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = palette.accentOrange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Location notes + channel badge: one tight unit so the document glyph and the
|
||||
// bluetooth/globe glyph sit at the same visual pitch as other header pairings.
|
||||
// Order, left to right: unread DMs, notes, channel, people. This cluster is measured
|
||||
// before the weighted nickname, so actions cannot be pushed off-screen by identity.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(0.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
LocationNotesButton(
|
||||
viewModel = viewModel,
|
||||
onClick = onLocationNotesClick
|
||||
)
|
||||
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
||||
HeaderIconButton(
|
||||
onClick = { viewModel.openLatestUnreadPrivateChat() },
|
||||
contentDescription = stringResource(R.string.cd_unread_private_messages)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_spec_envelope),
|
||||
contentDescription = stringResource(R.string.cd_unread_private_messages),
|
||||
modifier = Modifier.size(HeaderIconSize),
|
||||
tint = palette.accentOrange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bookmarking lives in the Location Channels sheet, one tap away via the channel
|
||||
// button. Duplicating it here bought a shortcut for a rare action at the cost
|
||||
// of a slot in the app's most crowded row.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
LocationNotesButton(
|
||||
viewModel = viewModel,
|
||||
onClick = onLocationNotesClick
|
||||
)
|
||||
|
||||
LocationChannelsButton(
|
||||
viewModel = viewModel,
|
||||
onClick = onLocationChannelsClick
|
||||
LocationChannelsButton(
|
||||
viewModel = viewModel,
|
||||
onClick = onLocationChannelsClick,
|
||||
showLabel = crowdingMode != HeaderCrowdingMode.IconOnlyLocationChannel
|
||||
)
|
||||
}
|
||||
|
||||
PeerCounter(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
|
||||
joinedChannels = joinedChannels,
|
||||
hasUnreadChannels = hasUnreadChannels,
|
||||
isConnected = isConnected,
|
||||
selectedLocationChannel = selectedLocationChannel,
|
||||
geohashPeople = geohashPeople,
|
||||
onClick = onSidebarClick,
|
||||
showJoinedChannelCount = crowdingMode == HeaderCrowdingMode.Full
|
||||
)
|
||||
}
|
||||
|
||||
PeerCounter(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
|
||||
joinedChannels = joinedChannels,
|
||||
hasUnreadChannels = hasUnreadChannels,
|
||||
isConnected = isConnected,
|
||||
selectedLocationChannel = selectedLocationChannel,
|
||||
geohashPeople = geohashPeople,
|
||||
onClick = onSidebarClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -805,7 +832,8 @@ private fun MainHeader(
|
||||
@Composable
|
||||
private fun LocationChannelsButton(
|
||||
viewModel: ChatViewModel,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
showLabel: Boolean
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
@ -832,35 +860,44 @@ private fun LocationChannelsButton(
|
||||
} else {
|
||||
R.drawable.ic_spec_range
|
||||
}
|
||||
val actionDescription = stringResource(R.string.cd_open_location_channels)
|
||||
val contentDescription = locationChannelContentDescription(
|
||||
actionDescription = actionDescription,
|
||||
channelLabel = badgeText,
|
||||
showLabel = showLabel
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
horizontalArrangement = if (showLabel) Arrangement.spacedBy(6.dp) else Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.clip(HeaderClusterShape)
|
||||
.pressScaleClickable(
|
||||
onClick = onClick,
|
||||
onClickLabel = stringResource(R.string.location_channels_title)
|
||||
onClickLabel = actionDescription
|
||||
)
|
||||
.height(HeaderTapTarget)
|
||||
// No start padding: the notes icon is paired directly to the left; keep end
|
||||
// padding so the gap to PeerCounter matches other cluster separations.
|
||||
.padding(start = 0.dp, end = 6.dp)
|
||||
.widthIn(min = HeaderTapTarget)
|
||||
.padding(end = if (showLabel) 6.dp else 0.dp)
|
||||
) {
|
||||
TorAwareHeaderIcon(
|
||||
painter = painterResource(badgeIconRes),
|
||||
tint = torVisual.tint,
|
||||
isProgress = torVisual.isProgress,
|
||||
contentDescription = stringResource(R.string.cd_tor_status)
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
|
||||
Text(
|
||||
text = badgeText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontSize = HeaderTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = channelColor,
|
||||
maxLines = 1
|
||||
)
|
||||
if (showLabel) {
|
||||
Text(
|
||||
text = badgeText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontSize = HeaderTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = channelColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.widthIn(max = 88.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user