Merge branch 'main' into main

This commit is contained in:
hafil 2026-08-25 21:43:50 +05:30 committed by GitHub
commit 19be706612
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
332 changed files with 47141 additions and 4081 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

6
.dockerignore Normal file
View File

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

View File

@ -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"

View File

@ -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 }}

View File

@ -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
View File

@ -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
View File

@ -0,0 +1 @@
21.0.11

176
AGENTS.md
View File

@ -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
View 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.*

View File

@ -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

View File

@ -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
View File

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

View File

@ -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 { *; }

View File

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

View File

@ -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 {

View File

@ -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

View File

@ -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: "&copy; OpenStreetMap &copy; 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>

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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) { }

View File

@ -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() &&

View File

@ -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)
},
)
}

View File

@ -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 }

View File

@ -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")
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}
}

View File

@ -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) {

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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,

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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

View File

@ -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}")
}

View File

@ -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()

View File

@ -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() {

View File

@ -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,

View File

@ -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}")

View File

@ -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)

View File

@ -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

View File

@ -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()
}
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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,

View File

@ -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(
}
}
}

View File

@ -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),

View File

@ -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

View File

@ -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
}
}

View File

@ -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

View File

@ -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" }

View File

@ -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) {

View File

@ -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
}
}

View File

@ -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

View File

@ -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 -> {

View File

@ -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
*/

View File

@ -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) {

View File

@ -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)
}

View File

@ -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()
}
}
}
}

View File

@ -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(

View File

@ -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()

View File

@ -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)
}
}
}

View File

@ -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()

View File

@ -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
)

View File

@ -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

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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()

View File

@ -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()
}
}

View File

@ -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)

View File

@ -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,

View File

@ -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)
)
}
}

View File

@ -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)
)
}
}
}

View 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 }
}
}

View File

@ -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 ->

View File

@ -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