Compare commits

..

No commits in common. "main" and "1.7.4" have entirely different histories.
main ... 1.7.4

559 changed files with 10703 additions and 80330 deletions

View File

@ -1,260 +0,0 @@
---
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

@ -1,59 +0,0 @@
{
"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

@ -1,271 +0,0 @@
# 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

@ -1,118 +0,0 @@
#!/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

@ -1,255 +0,0 @@
---
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

@ -1,32 +0,0 @@
{
"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

@ -1,20 +0,0 @@
<!-- 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

@ -1,41 +0,0 @@
{
"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

@ -1,152 +0,0 @@
# 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

@ -1,209 +0,0 @@
# 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

@ -1,97 +0,0 @@
# 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

@ -1,201 +0,0 @@
#!/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

@ -1,217 +0,0 @@
#!/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

@ -1,168 +0,0 @@
#!/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

@ -1,224 +0,0 @@
---
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

@ -1,41 +0,0 @@
{
"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."
]
}
]
}

View File

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

View File

@ -3,140 +3,102 @@ name: Android CI
on:
workflow_dispatch:
push:
branches: [main, develop]
branches: [ "main", "develop" ]
pull_request:
branches: [main, develop]
permissions:
contents: read
env:
SETUP_JAVA_VERSION: 21.0.11+10.0.LTS
branches: [ "main", "develop" ]
jobs:
verify:
name: Test and lint
runs-on: ubuntu-24.04
name: Test & Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Checkout code
uses: actions/checkout@v4
- name: Set up pinned JDK
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
with:
distribution: temurin
java-version: ${{ env.SETUP_JAVA_VERSION }}
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Set up and validate Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- name: Setup Gradle
uses: gradle/gradle-build-action@v3
- name: Verify native library inputs
run: tools/arti-build/verify-checksums.sh
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- 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 lint
run: ./gradlew lintDebug
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Upload test reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: test-reports
path: |
**/build/test-results/
**/build/reports/tests/
- name: Run lint
run: ./gradlew lintDebug
- name: Upload lint results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: lint-results
path: "**/build/reports/lint-results-*.html"
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: test-reports
path: |
**/build/test-results/
**/build/reports/tests/
build-debug:
name: Build debug APK
runs-on: ubuntu-24.04
needs: verify
- name: Upload lint results
uses: actions/upload-artifact@v4
if: always()
with:
name: lint-results
path: '**/build/reports/lint-results-*.html'
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
build:
name: Build ${{ matrix.variant }}
runs-on: ubuntu-latest
needs: verify
strategy:
fail-fast: false
matrix:
replica: [a, b]
variant: [Debug, Release]
steps:
- name: Checkout code
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Checkout code
uses: actions/checkout@v4
- name: Set up pinned JDK
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
with:
distribution: temurin
java-version: ${{ env.SETUP_JAVA_VERSION }}
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Set up and validate Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
cache-disabled: true
- name: Setup Gradle
uses: gradle/gradle-build-action@v3
- name: Build canonical unsigned release
run: tools/reproducible-builds/build-in-container.sh "$RUNNER_TEMP/release-${{ matrix.replica }}"
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- 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: 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-
compare-reproducible-builds:
name: Compare release bytes
runs-on: ubuntu-24.04
needs: reproducible-build
- name: Build ${{ matrix.variant }} APK
run: ./gradlew assemble${{ matrix.variant }}
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"
- name: Upload ${{ matrix.variant }} APK
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.variant }}-apk
path: app/build/outputs/apk/**/*.apk

View File

@ -10,11 +10,11 @@ permissions:
jobs:
update-relay-data:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
uses: actions/checkout@v4
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,118 +2,106 @@ name: Release
on:
workflow_dispatch:
inputs:
tag:
description: Existing vX.Y.Z tag to build and attest
required: true
type: string
push:
tags:
- "v*.*.*"
permissions:
contents: read
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
- 'v*.*.*' # Triggers for tags like v1.0.0
jobs:
build:
name: Build unsigned release ${{ matrix.replica }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
replica: [a, b]
runs-on: ubuntu-latest
steps:
- name: Validate release tag
- 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
run: |
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
echo "APK files built:"
find app/build/outputs/apk/release -name "*.apk" -type f -exec ls -lh {} \;
- name: Checkout tagged source
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
ref: ${{ env.RELEASE_TAG }}
- name: Verify workflow provenance ref
- name: Rename APKs for GitHub Release
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
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: Validate the Gradle wrapper
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
- 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
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 }}/*
name: bitchat-android-release-${{ github.ref_name }}
path: app/build/outputs/apk/release/*.apk
retention-days: 30
if-no-files-found: error
compare:
name: Verify reproducibility
runs-on: ubuntu-24.04
release:
needs: build
permissions:
attestations: write
contents: read
id-token: write
runs-on: ubuntu-latest
steps:
- name: Checkout verification script
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
ref: ${{ env.RELEASE_TAG }}
name: bitchat-android-release-${{ github.ref_name }}
path: release
- name: Download replica A
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
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
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 }}

11
.gitignore vendored
View File

@ -7,7 +7,6 @@ build/
!*/build/intermediates/
local.properties
.gradle/
.kotlin/
captures/
.externalNativeBuild/
debug_keystore/
@ -41,11 +40,6 @@ dependency-reduced-pom.xml
# Linters
.lint/
# Python test tooling
**/__pycache__/
*.py[cod]
release-gate-results/
# Other
*.log
.cxx/
@ -57,7 +51,6 @@ gen/
*~
*.swp
*.lock
!tools/arti-build/Cargo.lock
.goosehints
# Google services
@ -70,7 +63,3 @@ 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

View File

@ -1 +0,0 @@
21.0.11

171
AGENTS.md
View File

@ -1,127 +1,74 @@
# Repository Guidelines
# Bitchat Android - Agent Guide
## Privacy & External Disclosure (Mandatory)
This document provides context, architectural insights, and development standards for AI agents working on the Bitchat Android codebase.
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.
## 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.
- 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.
**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)
## Project Structure & Architecture
## 2. Architecture & Directory Structure
The application follows a clean architecture pattern, heavily modularized by feature within the `app` module.
`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/`.
**Root Package:** `com.bitchat.android`
`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.
| 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. |
## Build, Test & Development Commands
## 3. Key Components
Use JDK 21 and the Android SDK versions in `gradle/libs.versions.toml`.
### 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`.
```sh
./gradlew :app:assembleDebug :wear:assembleDebug
./gradlew testDebugUnitTest lintDebug
./gradlew connectedAndroidTest
./gradlew clientRewriteContractTest
tools/arti-build/verify-checksums.sh
```
### 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.
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.
## 4. Development Standards
## Coding Style & Naming
### 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`).
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.
### 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.
- **Execution**:
- Unit: `./gradlew test`
- Instrumented: `./gradlew connectedAndroidTest`
Protocol and security changes must remain fail-closed and cross-client
compatible. Update the relevant specification and golden-vector tests.
## 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`.
## Testing & Physical Mesh Lab
## 6. Common Tasks
- **Build Debug APK**: `./gradlew assembleDebug`
- **Lint Check**: `./gradlew lint`
- **Clean Build**: `./gradlew clean`
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.
---
*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.*

178
CHANGELOG.md Normal file
View File

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

1
GOOGLE_PLAY.md Normal file
View File

@ -0,0 +1 @@
I, callebtc, creator of bitchat for android, owner of the copyright claims, and owner of the official bitchat android repository (https://github.com/permissionlesstech/bitchat-android), hereby authorize Verse Communication PBC to publish bitchat (com.bitchat.doid) on the Google Play Store.

341
README.md
View File

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

View File

@ -1,42 +1,20 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.compose)
}
val githubReleaseCertSha256 = providers
.environmentVariable("BITCHAT_GITHUB_RELEASE_CERT_SHA256")
.orElse(providers.gradleProperty("BITCHAT_GITHUB_RELEASE_CERT_SHA256"))
.orElse("")
val normalizedGithubReleaseCertSha256 = githubReleaseCertSha256.get()
.replace(":", "")
.trim()
.lowercase()
require(
normalizedGithubReleaseCertSha256.isEmpty() ||
normalizedGithubReleaseCertSha256.matches(Regex("[a-f0-9]{64}"))
) {
"BITCHAT_GITHUB_RELEASE_CERT_SHA256 must be a SHA-256 certificate fingerprint"
}
android {
namespace = "com.bitchat.android"
compileSdk = libs.versions.compileSdk.get().toInt()
buildToolsVersion = libs.versions.buildTools.get()
defaultConfig {
applicationId = "com.bitchat.droid"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 38
versionName = "2.0.1"
buildConfigField(
"String",
"GITHUB_RELEASE_CERT_SHA256",
"\"$normalizedGithubReleaseCertSha256\""
)
versionCode = 35
versionName = "1.7.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -65,11 +43,6 @@ 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
}
}
}
@ -92,12 +65,14 @@ android {
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
@ -111,20 +86,6 @@ android {
}
}
composeCompiler {
// Kotlin 2.4.10's optional Compose group-key mapping depends on unspecified
// class-file iteration order. Keep the normal R8 mapping, but omit that
// augmentation until its producer is deterministic across clean builds.
includeComposeMappingFile.set(false)
}
kotlin {
jvmToolchain(21)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
dependencies {
// Core Android dependencies
implementation(libs.androidx.core.ktx)
@ -169,12 +130,6 @@ dependencies {
// WebSocket
implementation(libs.okhttp)
// WorkManager for background APK downloads
implementation(libs.androidx.work.runtime.ktx)
// HTTP Server for hotspot APK sharing
implementation(libs.nanohttpd)
// Arti (Tor in Rust) Android bridge - custom build from latest source
// Built with rustls, 16KB page size support, and onio//un service client
// Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar)
@ -188,7 +143,7 @@ dependencies {
implementation(libs.androidx.security.crypto)
// EXIF orientation handling for images
implementation(libs.androidx.exifinterface)
implementation("androidx.exifinterface:exifinterface:1.3.7")
// Testing
testImplementation(libs.bundles.testing)
@ -196,12 +151,3 @@ dependencies {
androidTestImplementation(libs.bundles.compose.testing)
debugImplementation(libs.androidx.compose.ui.tooling)
}
// Robolectric resolves Android runtime jars itself (outside Gradle dependency resolution).
// Its legacy repo1 endpoint rejects cold GitHub-hosted runners with HTTP 403.
tasks.withType<org.gradle.api.tasks.testing.Test>().configureEach {
systemProperty(
"robolectric.dependency.repo.url",
"https://repo.maven.apache.org/maven2"
)
}

View File

@ -1,464 +0,0 @@
# 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,13 +17,6 @@
-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

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

View File

@ -1,142 +0,0 @@
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

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

View File

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

View File

@ -22,18 +22,11 @@
<!-- Notification permissions -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- WiFi / WiFi Aware permissions (also used for hotspot APK sharing) -->
<!-- WiFi / WiFi Aware permissions -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Android 13+ runtime permission for WiFi operations (including Aware and WiFi P2P) -->
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- Android 17+ gates local network access; WiFi Aware peers over link-local IPv6 -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Keep hotspot alive while sharing the APK -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Android 13+ runtime permission for WiFi operations (including Aware) -->
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<!-- Signature permission for internal UI shutdown broadcasts -->
<uses-permission android:name="com.bitchat.android.permission.FORCE_FINISH" />
<!-- Foreground service and boot permissions for long-running background mesh -->
@ -80,21 +73,11 @@
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="33">
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
tools:targetApi="31">
<!-- FileProvider for sharing temp/cache files with external viewers -->
<provider
android:name="androidx.core.content.FileProvider"
@ -137,10 +120,15 @@
tools:ignore="DataExtractionRules">
</service>
<!-- Listen for in-app broadcast when POST_NOTIFICATIONS is granted -->
<receiver
android:name=".service.ConversationNotificationReceiver"
android:name=".service.NotificationPermissionChangedReceiver"
android:enabled="true"
android:exported="false" />
android:exported="false">
<intent-filter>
<action android:name="com.bitchat.android.action.NOTIFICATION_PERMISSION_GRANTED" />
</intent-filter>
</receiver>
<!-- Auto-start mesh service after boot if enabled -->
<receiver
@ -152,20 +140,5 @@
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- Hotspot Activity for offline APK sharing -->
<activity
android:name=".hotspot.HotspotActivity"
android:exported="false"
android:label="Share BitChat"
android:theme="@style/Theme.BitchatAndroid"
android:launchMode="singleTop" />
<!-- Declare the foreground service type for WorkManager's foreground
service so the APK download worker can run as dataSync work -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 370 B

View File

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

Before

Width:  |  Height:  |  Size: 362 B

View File

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

Before

Width:  |  Height:  |  Size: 955 B

View File

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

Before

Width:  |  Height:  |  Size: 338 B

View File

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

Before

Width:  |  Height:  |  Size: 555 B

View File

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

Before

Width:  |  Height:  |  Size: 687 B

View File

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

Before

Width:  |  Height:  |  Size: 792 B

View File

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

Before

Width:  |  Height:  |  Size: 738 B

View File

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

Before

Width:  |  Height:  |  Size: 395 B

View File

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

Before

Width:  |  Height:  |  Size: 541 B

View File

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

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

Before

Width:  |  Height:  |  Size: 655 B

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

Before

Width:  |  Height:  |  Size: 638 B

View File

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

Before

Width:  |  Height:  |  Size: 388 B

View File

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

Before

Width:  |  Height:  |  Size: 380 B

View File

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

Before

Width:  |  Height:  |  Size: 405 B

View File

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

Before

Width:  |  Height:  |  Size: 271 B

View File

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

Before

Width:  |  Height:  |  Size: 942 B

View File

@ -0,0 +1,225 @@
<!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,400 +1,414 @@
Relay URL,Latitude,Longitude
myvoiceourstory.org,37.3598,-121.981
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-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.nostr.blockhenge.com,39.0438,-77.4874
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
relay.zone667.com,60.1699,24.9384
relay.typedcypher.com,51.5072,-0.127586
wot.nostr.place,43.6532,-79.3832
nostr.myshosholoza.co.za:443,52.3913,4.66545
relay.homeinhk.xyz,35.694,139.754
nostr.ps1829.com:443,33.8851,130.883
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay.underorion.se,50.1109,8.68213
nostr-relay.amethyst.name:443,39.0067,-77.4291
relay.mitchelltribe.com:443,39.0438,-77.4874
bitchat.nostr1.com,40.7057,-74.0136
0x-nostr-relay.fly.dev,37.7648,-122.432
nostr.blankfors.se,60.1699,24.9384
relay.solife.me,43.6532,-79.3832
relay.snotr.nl:49999,52.0195,4.42946
nostr-relay.corb.net:443,38.8353,-104.822
relay.klabo.world,47.2343,-119.853
relay02.lnfi.network,35.6764,139.65
ribo.us.nostria.app,43.6532,-79.3832
nostr.quali.chat:443,60.1699,24.9384
offchain.pub:443,39.1585,-94.5728
pool.libernet.app,43.6532,-79.3832
nostr.bond,50.1109,8.68213
soloco.nl,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627
schnorr.me,43.6532,-79.3832
ec2.f7z.io,60.1699,24.9384
relay.gulugulu.moe:443,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
nostr.vulpem.com,49.4543,11.0746
rilo.nostria.app,43.6532,-79.3832
adre.su,59.9311,30.3609
ribo.eu.nostria.app:443,43.6532,-79.3832
vault.iris.to,43.6532,-79.3832
strfry.shock.network:443,39.0438,-77.4874
x.kojira.io,43.6532,-79.3832
ribo.nostria.app:443,43.6532,-79.3832
nostr-verified.wellorder.net,45.5201,-122.99
relay.chorus.community:443,48.5333,10.7
nostr.aruku.ovh,1.27994,103.849
relay.internationalright-wing.org:443,-22.5022,-48.7114
relay.getsafebox.app,43.6532,-79.3832
relay-dev.satlantis.io:443,40.8302,-74.1299
kasztanowa.bieda.it,43.6532,-79.3832
relay.bullishbounty.com:443,43.6532,-79.3832
relay.nostrian-conquest.com:443,41.223,-111.974
relay.kilombino.com,43.6532,-79.3832
purplerelay.com,43.6532,-79.3832
relay.erybody.com,41.4513,-81.7021
nostr.na.social,43.6532,-79.3832
relay.wavefunc.live,41.8781,-87.6298
nostr.tagomago.me,42.3601,-71.0589
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
relay.wellorder.net,45.5201,-122.99
node.kommonzenze.de,49.4521,11.0767
nostr.pbfs.io,50.4754,12.3683
fanfares.nostr1.com:443,40.7057,-74.0136
relay.btcforplebs.com,43.6532,-79.3832
relay.nostrian-conquest.com,41.223,-111.974
strfry.shock.network,39.0438,-77.4874
relay.satmaxt.xyz:443,43.6532,-79.3832
relay.fckstate.net,59.3293,18.0686
relayone.geektank.ai,39.1008,-94.5811
relayrs.notoshi.win:443,43.6532,-79.3832
wot.nostr.party,36.1659,-86.7844
testnet.samt.st,43.6532,-79.3832
nostr.stakey.net:443,52.3676,4.90414
nostr.spaceshell.xyz,43.6532,-79.3832
nostr.n7ekb.net,47.4941,-122.294
relay.snort.social,53.3498,-6.26031
social.amanah.eblessing.co,48.1046,11.6002
relay.veganostr.com:443,60.1699,24.9384
relay2.angor.io:443,48.1046,11.6002
nostr.overmind.lol:443,43.6532,-79.3832
nostr.ps1829.com,33.8851,130.883
relay-dev.gulugulu.moe:443,43.6532,-79.3832
wot.shaving.kiwi,43.6532,-79.3832
relay.typedcypher.com:443,51.5072,-0.127586
relay.openfarmtools.org,60.1699,24.9384
wot.brightbolt.net,47.6735,-116.781
relay.libernet.app,43.6532,-79.3832
slick.mjex.me,39.0418,-77.4744
relay.nostr.net,43.6532,-79.3832
nostr.chaima.info,50.1109,8.68213
relay.wavlake.com:443,41.2619,-95.8608
relay.cosmicbolt.net,37.3986,-121.964
relay.paulstephenborile.com:443,49.4543,11.0746
relay.nostr.place:443,43.6532,-79.3832
testnet-relay.samt.st,40.8302,-74.1299
testnet-relay.samt.st:443,40.8302,-74.1299
relay.islandbitcoin.com,12.8498,77.6545
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
relay.ohstr.com:443,43.6532,-79.3832
nostr.88mph.life,52.1941,-2.21905
relay.layer.systems:443,49.0291,8.35695
schnorr.me:443,43.6532,-79.3832
nostr-relay.psfoundation.info:443,39.0438,-77.4874
nostr.snowbla.de:443,60.1699,24.9384
relay.olas.app,60.1699,24.9384
espelho.girino.org,43.6532,-79.3832
nostr.unkn0wn.world,46.8499,9.53287
relayone.geektank.ai:443,39.1008,-94.5811
nostr.easycryptosend.it,43.6532,-79.3832
thecitadel.nostr1.com,40.7057,-74.0136
relay.gulugulu.moe,43.6532,-79.3832
relay.lab.rytswd.com:443,49.4543,11.0746
nos.lol,50.4754,12.3683
relay.plebeian.market:443,50.1109,8.68213
relay.getsafebox.app:443,43.6532,-79.3832
relay.mitchelltribe.com,39.0438,-77.4874
relay-dev.gulugulu.moe,43.6532,-79.3832
relay.trotters.cc,43.6532,-79.3832
relay.ditto.pub,43.6532,-79.3832
relay.ditto.pub:443,43.6532,-79.3832
relay.fountain.fm,43.6532,-79.3832
nostr.2b9t.xyz,34.0549,-118.243
herbstmeister.com,34.0549,-118.243
dev.relay.edufeed.org:443,49.4521,11.0767
relay2.veganostr.com,60.1699,24.9384
nostr.purpura.cloud,43.6532,-79.3832
nostr.quali.chat,60.1699,24.9384
relay.paulstephenborile.com,49.4543,11.0746
relay.0xchat.com,43.6532,-79.3832
articles.layer3.news,37.3387,-121.885
nostrcity-club.fly.dev:443,37.7648,-122.432
relay.endfiat.money:443,59.3327,18.0656
relay.samt.st,40.8302,-74.1299
relay.mostr.pub,43.6532,-79.3832
relay.mccormick.cx,52.3563,4.95714
nostrride.io,37.3986,-121.964
mostro-p2p.tech,50.1109,8.68213
relay.toastr.net,40.8054,-74.0241
reraw.pbla2fish.cc,43.6532,-79.3832
nostr.self-determined.de,53.495,10.2542
temp.iris.to,43.6532,-79.3832
relay.openresist.com:443,43.6532,-79.3832
relay.mostro.network:443,40.8302,-74.1299
srtrelay.c-stellar.net,43.6532,-79.3832
nostr.stakey.net,52.3676,4.90414
nostr.chaima.info:443,50.1109,8.68213
dev.relay.edufeed.org,49.4521,11.0767
nostr.computingcache.com:443,34.0356,-118.442
relay.sigit.io,50.4754,12.3683
nostr.oxtr.dev:443,50.4754,12.3683
relay.angor.io,48.1046,11.6002
kotukonostr.onrender.com,37.7775,-122.397
zealand-charts-craig-thru.trycloudflare.com,43.6532,-79.3832
shu01.shugur.net,21.4902,39.2246
relayone.soundhsa.com:443,39.1008,-94.5811
relay2.angor.io,48.1046,11.6002
nittom.nostr1.com,40.7057,-74.0136
nostr.girino.org:443,43.6532,-79.3832
nexus.libernet.app,43.6532,-79.3832
bitcoiner.social,47.6743,-117.112
nostr2.girino.org,43.6532,-79.3832
relaisnostr.trivaco.fr,49.4282,10.9796
relay.edufeed.org:443,49.4521,11.0767
relay.binaryrobot.com,43.6532,-79.3832
insta-relay.apps3.slidestr.net,40.4167,-3.70329
relay.endfiat.money,59.3327,18.0656
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
nrs-01.darkcloudarcade.com:443,39.1008,-94.5811
nostr.liberty.fans,36.9104,-89.5875
relay.mmwaves.de,48.8575,2.35138
relay.inforsupports.com,43.6532,-79.3832
dev-nostr.bityacht.io,43.6532,-79.3832
yabu.me,35.6092,139.73
relay.0xchat.com:443,43.6532,-79.3832
relay.agentry.com,42.8864,-78.8784
relay.mrmave.work,43.6532,-79.3832
nostr.carroarmato0.be,50.914,3.21378
nostr.myshosholoza.co.za,52.3913,4.66545
nostr.thebiglake.org,32.71,-96.6745
relay.kalcafe.xyz,37.3986,-121.964
nostr.openhoofd.nl,51.5717,3.70417
relayone.soundhsa.com,39.1008,-94.5811
relay.bullishbounty.com,43.6532,-79.3832
satsage.xyz,37.3986,-121.964
relay-testnet.k8s.layer3.news,37.3387,-121.885
bridge.tagomago.me,42.3601,-71.0589
relay.sigit.io:443,50.4754,12.3683
relay.mypathtofire.de,42.8864,-78.8784
relay.dreamith.to,43.6532,-79.3832
relay.wavlake.com,41.2619,-95.8608
relay.lanacoin-eternity.com,40.8302,-74.1299
nostr.2b9t.xyz:443,34.0549,-118.243
nostr.notribe.net,40.8302,-74.1299
relay.nostrdice.com,-33.8688,151.209
nostr-relay.amethyst.name,39.0067,-77.4291
relay.mccormick.cx:443,52.3563,4.95714
nostr.4rs.nl,49.0291,8.35696
relay.dwadziesciajeden.pl,52.2297,21.0122
spookstr2.nostr1.com:443,40.7057,-74.0136
relay.vrtmrz.net:443,43.6532,-79.3832
relay.minibolt.info:443,43.6532,-79.3832
nostrelites.org,41.8781,-87.6298
relay.nostar.org,43.6532,-79.3832
nostriches.club,43.6532,-79.3832
rele.speyhard.fi,51.5072,-0.127586
wot.utxo.one,43.6532,-79.3832
relay1.orangesync.tech,44.7839,-106.941
damus.bostr.online,43.6532,-79.3832
relay.islandbitcoin.com:443,12.8498,77.6545
relay.mulatta.io,37.5665,126.978
strfry.openhoofd.nl,51.5717,3.70417
cs-relay.nostrdev.com,50.4754,12.3683
nostr.bitcoiner.social,47.6743,-117.112
relay.thecryptosquid.com,50.4754,12.3683
nostr.computingcache.com,34.0356,-118.442
nostr-relay.psfoundation.info,39.0438,-77.4874
relay5.bitransfer.org,43.6532,-79.3832
relay.damus.io,43.6532,-79.3832
strfry.ymir.cloud,43.6532,-79.3832
dev.relay.stream,43.6532,-79.3832
relay.vrtmrz.net,43.6532,-79.3832
myvoiceourstory.org,37.3598,-121.981
strfry.openhoofd.nl:443,51.5717,3.70417
spookstr2.nostr1.com,40.7057,-74.0136
bitcoiner.social:443,47.6743,-117.112
aeon.libretechsystems.xyz,55.486,9.86577
relayrs.notoshi.win,43.6532,-79.3832
nostr.pbfs.io:443,50.4754,12.3683
public.crostr.com,43.6532,-79.3832
nostr.islandarea.net,35.4669,-97.6473
rilo.nostria.app:443,43.6532,-79.3832
blossom.gnostr.cloud,43.6532,-79.3832
relay.aarpia.com,37.3986,-121.964
relay.arx-ccn.com,50.4754,12.3683
conduitl2.fly.dev,37.7648,-122.432
relay.nostrmap.net,60.1699,24.9384
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
nostrelay.circum.space,52.6907,4.8181
syb.lol:443,43.6532,-79.3832
relay.artx.market,43.6548,-79.3885
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
blossom.gnostr.cloud:443,43.6532,-79.3832
x.kojira.io:443,43.6532,-79.3832
nostr.tac.lol,47.4748,-122.273
relay-rpi.edufeed.org,49.4521,11.0767
testr.nymble.world,40.8054,-74.0241
relay.beginningend.com:443,35.2227,-97.4786
relay.openresist.com,43.6532,-79.3832
relay.minibolt.info,43.6532,-79.3832
nostr.thalheim.io,60.1699,24.9384
relay.notoshi.win:443,13.3622,100.983
vault.iris.to:443,43.6532,-79.3832
top.testrelay.top,43.6532,-79.3832
nostrbtc.com,43.6532,-79.3832
relay.nearhood.co.uk,51.5072,-0.127586
nostr.overmind.lol,43.6532,-79.3832
relay.agorist.space,52.3734,4.89406
nrs-01.darkcloudarcade.com,39.1008,-94.5811
relay.artx.market:443,43.6548,-79.3885
nos.lol:443,50.4754,12.3683
relay.illuminodes.com,43.6532,-79.3832
relay.laantungir.net,-19.4692,-42.5315
relay.plebchain.club,43.6532,-79.3832
nostr.girino.org,43.6532,-79.3832
nostrja-kari.heguro.com,43.6532,-79.3832
nostrsgp.notribe.net,1.32123,103.695
nostr.thalheim.io:443,60.1699,24.9384
prl.plus,55.7628,37.5983
nostr.carroarmato0.be:443,50.914,3.21378
nostr.nodesmap.com,59.3327,18.0656
bucket.coracle.social,37.7775,-122.397
relay.lightning.pub,39.0438,-77.4874
relay.dreamith.to:443,43.6532,-79.3832
nostr.na.social:443,43.6532,-79.3832
nostr.debate.report,50.1109,8.68213
relay2.orangesync.tech,40.7128,-74.006
relay.comcomponent.com,43.6532,-79.3832
nostr-check.me,43.6532,-79.3832
nostr.oxtr.dev,50.4754,12.3683
nostr.21crypto.ch,47.5356,8.73209
relay.sincensura.org,43.6532,-79.3832
relay.internationalright-wing.org,-22.5022,-48.7114
antiprimal.net,43.6532,-79.3832
nostr.rtvslawenia.com,49.4543,11.0746
relay.nostr.place,43.6532,-79.3832
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
nostr.wecsats.io:443,43.6532,-79.3832
cs-relay.nostrdev.com:443,50.4754,12.3683
nostr.azzamo.net,52.2633,21.0283
relay.cypherflow.ai:443,48.8575,2.35138
nostr.hifish.org,47.4244,8.57658
relay.wavefunc.live:443,41.8781,-87.6298
nostr.tac.lol:443,47.4748,-122.273
wot.dergigi.com,64.1476,-21.9392
nostr.wecsats.io,43.6532,-79.3832
relay-dev.satlantis.io,40.8302,-74.1299
nostr.sathoarder.com:443,48.5734,7.75211
cache.trustr.ing,43.6548,-79.3885
relay.directsponsor.net,42.8864,-78.8784
strfry.bonsai.com:443,39.0438,-77.4874
nostr-01.yakihonne.com,1.32123,103.695
relay.satlantis.io,40.8054,-74.0241
nostr-2.21crypto.ch:443,47.5356,8.73209
offchain.bostr.online,43.6532,-79.3832
test.thedude.cloud,50.1109,8.68213
relay.mostr.pub:443,43.6532,-79.3832
relay.libernet.app:443,43.6532,-79.3832
nostr.mom,50.4754,12.3683
nostr.rtvslawenia.com:443,49.4543,11.0746
relay.plebeian.market,50.1109,8.68213
wot.makenomistakes.ca,43.7064,-79.3986
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
ithurtswhenip.ee,51.5072,-0.127586
relay.staging.commonshub.brussels,49.4543,11.0746
relay.beginningend.com,35.2227,-97.4786
strfry.bonsai.com,39.0438,-77.4874
nostrelay.circum.space:443,52.6907,4.8181
relay.mostro.network,40.8302,-74.1299
dev-relay.nostreon.com,60.1699,24.9384
relay.fundstr.me,42.3601,-71.0589
relay.mmwaves.de:443,48.8575,2.35138
relay.nostu.be,40.4167,-3.70329
relay.nostx.io,43.6532,-79.3832
nostr.data.haus,50.4754,12.3683
relay.lightning.pub:443,39.0438,-77.4874
relay.nostr-check.me,43.6532,-79.3832
nostr.bitcoiner.social:443,47.6743,-117.112
ribo.nostria.app,43.6532,-79.3832
offchain.pub,39.1585,-94.5728
relay.layer.systems,49.0291,8.35695
nostr.dlcdevkit.com,40.0992,-83.1141
relay.angor.io:443,48.1046,11.6002
relay.mwaters.net,50.9871,2.12554
relay.nostriot.com:443,41.5695,-83.9786
nostrcity-club.fly.dev,37.7648,-122.432
nostr.notribe.net:443,40.8302,-74.1299
freelay.sovbit.host,60.1699,24.9384
syb.lol,43.6532,-79.3832
portal-relay.pareto.space,49.0291,8.35696
wot.codingarena.top,50.4754,12.3683
nostr-2.21crypto.ch,47.5356,8.73209
nostr.hifish.org:443,47.4244,8.57658
relay.zone667.com:443,60.1699,24.9384
relay.lab.rytswd.com,49.4543,11.0746
nas01xanthosnet.synology.me:7778,47.1285,8.74735
relay.primal.net,43.6532,-79.3832
relay.bitmacro.cloud,43.6532,-79.3832
chat-relay.zap-work.com:443,43.6532,-79.3832
relay.fountain.fm:443,43.6532,-79.3832
ribo.eu.nostria.app,43.6532,-79.3832
nexus.libernet.app:443,43.6532,-79.3832
nostr.sathoarder.com,48.5734,7.75211
nostr-pub.wellorder.net,45.5201,-122.99
nostr.snowbla.de,60.1699,24.9384
strfry.apps3.slidestr.net,40.4167,-3.70329
nostr.spicyz.io:443,43.6532,-79.3832
nostr.data.haus:443,50.4754,12.3683
nostr.dlcdevkit.com:443,40.0992,-83.1141
purplerelay.com:443,43.6532,-79.3832
nostr-relay.xbytez.io,50.6924,3.20113
relay.nostrmap.net:443,60.1699,24.9384
relay.piazza.today,48.122,11.589
relay.ohstr.com,43.6532,-79.3832
r.0kb.io:443,32.789,-96.7989
uncouth-panning-paternal.ngrok-free.dev,40.0992,-83.1141
wot.sudocarlos.com,43.6532,-79.3832
relay.satmaxt.xyz,43.6532,-79.3832
ribo.us.nostria.app:443,43.6532,-79.3832
no.str.cr,10.6352,-85.4378
relay.notoshi.win,13.3622,100.983
nostr.spicyz.io,43.6532,-79.3832
relay.veganostr.com,60.1699,24.9384
r.0kb.io,32.789,-96.7989
relay-testnet.k8s.layer3.news:443,37.3387,-121.885
relay.cosmicbolt.net:443,37.3986,-121.964
nostr-relay.xbytez.io:443,50.6924,3.20113
relay.sharegap.net,43.6532,-79.3832
bcast.girino.org,43.6532,-79.3832
nostr2.girino.org:443,43.6532,-79.3832
relay.cypherflow.ai,48.8575,2.35138
nostr-relay.corb.net,38.8353,-104.822
relay.staging.plebeian.market,51.5072,-0.127586
relay.lanavault.space,60.1699,24.9384
relay.wisp.talk,49.4543,11.0746
relay.wisp.talk:443,49.4543,11.0746
no.str.cr:443,10.6352,-85.4378
nostr.hekster.org:443,37.3986,-121.964
nostr-01.yakihonne.com:443,1.32123,103.695
dm-test-strfry-generic.samt.st,43.6532,-79.3832
relay.guggero.org,46.5971,9.59652
nostr.twinkle.lol,51.902,7.6657
nostr.janx.com,43.6532,-79.3832
relay.nostrhub.fr,48.1045,11.6004
relay.nostriot.com,41.5695,-83.9786
nostr.hekster.org,37.3986,-121.964
fanfares.nostr1.com,40.7057,-74.0136
nostr-relay.cbrx.io,43.6532,-79.3832
relay.edufeed.org,49.4521,11.0767
relay.olas.app:443,60.1699,24.9384
armada.sharegap.net,43.6532,-79.3832
nostr.islandarea.net:443,35.4669,-97.6473
relay0.gfcom.info,13.8434,100.363
relay.chorus.community,48.5333,10.7
nittom.nostr1.com:443,40.7057,-74.0136
relay.staging.plebeian.market:443,51.5072,-0.127586
chat-relay.zap-work.com,43.6532,-79.3832
relay-rpi.edufeed.org:443,49.4521,11.0767
relay.lanavault.space:443,60.1699,24.9384
relay.binaryrobot.com:443,43.6532,-79.3832
nostr.mom:443,50.4754,12.3683
relay.bornheimer.app,51.5072,-0.127586
treuzkas.branruz.com,48.8575,2.35138
nostr-dev.wellorder.net,45.5201,-122.99
public.crostr.com:443,43.6532,-79.3832
relay.degmods.com,50.4754,12.3683
articles.layer3.news:443,37.3387,-121.885
nostr.azzamo.net:443,52.2633,21.0283
premium.primal.net,43.6532,-79.3832

1 Relay URL Latitude Longitude
2 myvoiceourstory.org relay.nostr.blockhenge.com 37.3598 39.0438 -121.981 -77.4874
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-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
3 relay.agorist.space:443 52.3734 4.89406
4 nostr.easycryptosend.it relay.zone667.com 43.6532 60.1699 -79.3832 24.9384
5 x.kojira.io relay.typedcypher.com 43.6532 51.5072 -79.3832 -0.127586
6 adre.su wot.nostr.place 59.9311 43.6532 30.3609 -79.3832
7 offchain.bostr.online nostr.myshosholoza.co.za:443 43.6532 52.3913 -79.3832 4.66545
8 relay.libernet.app relay.homeinhk.xyz 43.6532 35.694 -79.3832 139.754
9 nostr.ps1829.com:443 33.8851 130.883
10 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
11 relay.underorion.se 50.1109 8.68213
12 nostr-relay.amethyst.name:443 39.0067 -77.4291
13 relay.mitchelltribe.com:443 39.0438 -77.4874
14 bitchat.nostr1.com 40.7057 -74.0136
15 0x-nostr-relay.fly.dev 37.7648 -122.432
16 nostr.blankfors.se 60.1699 24.9384
17 relay.solife.me 43.6532 -79.3832
18 relay.snotr.nl:49999 52.0195 4.42946
19 nostr-relay.corb.net:443 38.8353 -104.822
20 relay.klabo.world 47.2343 -119.853
21 relay02.lnfi.network 35.6764 139.65
22 ribo.us.nostria.app 43.6532 -79.3832
23 nostr.quali.chat:443 60.1699 24.9384
24 offchain.pub:443 39.1585 -94.5728
25 pool.libernet.app 43.6532 -79.3832
26 nostr.bond 50.1109 8.68213
27 soloco.nl 43.6532 -79.3832
28 relay.ru.ac.th 13.7607 100.627
29 schnorr.me 43.6532 -79.3832
30 ec2.f7z.io 60.1699 24.9384
31 relay.gulugulu.moe:443 43.6532 -79.3832
32 nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
33 nostr.rtvslawenia.com nostr.vulpem.com 49.4543 11.0746
34 relay.layer.systems rilo.nostria.app 49.0291 43.6532 8.35695 -79.3832
35 relay.arx-ccn.com adre.su 50.4754 59.9311 12.3683 30.3609
36 staging.yabu.me ribo.eu.nostria.app:443 35.6092 43.6532 139.73 -79.3832
37 relay.vrtmrz.net vault.iris.to 43.6532 -79.3832
38 nostr.bitcoiner.social strfry.shock.network:443 40.7608 39.0438 -111.891 -77.4874
39 nostr.88mph.life x.kojira.io 52.1941 43.6532 -2.21905 -79.3832
40 relay.pprgb.app ribo.nostria.app:443 52.52 43.6532 13.405 -79.3832
41 relay.aarpia.com nostr-verified.wellorder.net 37.3986 45.5201 -121.964 -122.99
42 nostrcity-club.fly.dev relay.chorus.community:443 38.7946 48.5333 -106.535 10.7
43 buzz.ac2n-share.kozow.com nostr.aruku.ovh 45.764 1.27994 4.83566 103.849
44 relay.fckstate.net relay.internationalright-wing.org:443 59.3293 -22.5022 18.0686 -48.7114
45 nostr.thalheim.io:443 relay.getsafebox.app 60.1699 43.6532 24.9384 -79.3832
46 nostr.carroarmato0.be:443 relay-dev.satlantis.io:443 50.914 40.8302 3.21378 -74.1299
47 nostrcity-club.fly.dev:443 kasztanowa.bieda.it 38.7946 43.6532 -106.535 -79.3832
48 relay.bullishbounty.com:443 43.6532 -79.3832
49 relay.nostrian-conquest.com:443 41.223 -111.974
50 relay.kilombino.com 43.6532 -79.3832
51 purplerelay.com 43.6532 -79.3832
52 relay.erybody.com 41.4513 -81.7021
53 nostr.na.social 43.6532 -79.3832
54 relay.wavefunc.live 41.8781 -87.6298
55 nostr.tagomago.me 42.3601 -71.0589
56 relay.routstr.com 59.4016 17.9455
57 nostr-dev.wellorder.net relay.wellorder.net 45.5201 -122.99
58 relay.directsponsor.net node.kommonzenze.de 42.8864 49.4521 -78.8784 11.0767
59 relay.mwaters.net nostr.pbfs.io 50.9871 50.4754 2.12554 12.3683
60 relay.littlebitstudios.com fanfares.nostr1.com:443 43.6532 40.7057 -79.3832 -74.0136
61 relay.ohstr.com relay.btcforplebs.com 43.6532 -79.3832
62 relay.nostrian-conquest.com 41.223 -111.974
63 strfry.shock.network 39.0438 -77.4874
64 relay.satmaxt.xyz:443 43.6532 -79.3832
65 relay.fckstate.net 59.3293 18.0686
66 relayone.geektank.ai 39.1008 -94.5811
67 relayrs.notoshi.win:443 43.6532 -79.3832
68 wot.nostr.party 36.1659 -86.7844
69 testnet.samt.st 43.6532 -79.3832
70 nostr.stakey.net:443 52.3676 4.90414
71 nostr.spaceshell.xyz 43.6532 -79.3832
72 nostr.n7ekb.net 47.4941 -122.294
73 relay.snort.social 53.3498 -6.26031
74 social.amanah.eblessing.co 48.1046 11.6002
75 relay.veganostr.com:443 60.1699 24.9384
76 relay2.angor.io:443 48.1046 11.6002
77 nostr.overmind.lol:443 43.6532 -79.3832
78 nostr.ps1829.com 33.8851 130.883
79 relay-dev.gulugulu.moe:443 43.6532 -79.3832
80 wot.shaving.kiwi 43.6532 -79.3832
81 relay.typedcypher.com:443 51.5072 -0.127586
82 relay.openfarmtools.org 60.1699 24.9384
83 wot.brightbolt.net 47.6735 -116.781
84 relay.libernet.app 43.6532 -79.3832
85 slick.mjex.me 39.0418 -77.4744
86 relay.nostr.net 43.6532 -79.3832
87 nostr.chaima.info 50.1109 8.68213
88 relay.wavlake.com:443 41.2619 -95.8608
89 relay.cosmicbolt.net 37.3986 -121.964
90 relay.paulstephenborile.com:443 49.4543 11.0746
91 relay.nostr.place:443 43.6532 -79.3832
92 testnet-relay.samt.st 40.8302 -74.1299
93 testnet-relay.samt.st:443 40.8302 -74.1299
94 relay.islandbitcoin.com 12.8498 77.6545
95 dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
96 relay.ohstr.com:443 43.6532 -79.3832
97 nostr.88mph.life 52.1941 -2.21905
98 relay.layer.systems:443 49.0291 8.35695
99 schnorr.me:443 43.6532 -79.3832
100 nostr-relay.psfoundation.info:443 39.0438 -77.4874
101 nostr.snowbla.de:443 60.1699 24.9384
102 relay.olas.app 60.1699 24.9384
103 espelho.girino.org 43.6532 -79.3832
104 nostr.unkn0wn.world 46.8499 9.53287
105 relayone.geektank.ai:443 39.1008 -94.5811
106 nostr.easycryptosend.it 43.6532 -79.3832
107 thecitadel.nostr1.com 40.7057 -74.0136
108 relay.gulugulu.moe 43.6532 -79.3832
109 relay.lab.rytswd.com:443 49.4543 11.0746
110 nos.lol 50.4754 12.3683
111 relay.plebeian.market:443 50.1109 8.68213
112 relay.getsafebox.app:443 43.6532 -79.3832
113 relay.mitchelltribe.com 39.0438 -77.4874
114 relay-dev.gulugulu.moe 43.6532 -79.3832
115 relay.trotters.cc 43.6532 -79.3832
116 relay.ditto.pub 43.6532 -79.3832
117 relay.ditto.pub:443 43.6532 -79.3832
118 relay.fountain.fm 43.6532 -79.3832
119 nostr.2b9t.xyz 34.0549 -118.243
120 herbstmeister.com 34.0549 -118.243
121 dev.relay.edufeed.org:443 49.4521 11.0767
122 relay2.veganostr.com 60.1699 24.9384
123 nostr.purpura.cloud 43.6532 -79.3832
124 nostr.quali.chat 60.1699 24.9384
125 relay.paulstephenborile.com 49.4543 11.0746
126 relay.0xchat.com 43.6532 -79.3832
127 articles.layer3.news 37.3387 -121.885
128 nostrcity-club.fly.dev:443 37.7648 -122.432
129 relay.endfiat.money:443 59.3327 18.0656
130 relay.samt.st 40.8302 -74.1299
131 relay.mostr.pub 43.6532 -79.3832
132 relay.mccormick.cx 52.3563 4.95714
133 nostrride.io 37.3986 -121.964
134 mostro-p2p.tech 50.1109 8.68213
135 relay.toastr.net 40.8054 -74.0241
136 reraw.pbla2fish.cc 43.6532 -79.3832
137 nostr.self-determined.de 53.495 10.2542
138 temp.iris.to 43.6532 -79.3832
139 relay.openresist.com:443 43.6532 -79.3832
140 relay.mostro.network:443 40.8302 -74.1299
141 srtrelay.c-stellar.net 43.6532 -79.3832
142 nostr.stakey.net 52.3676 4.90414
143 nostr.chaima.info:443 50.1109 8.68213
144 dev.relay.edufeed.org 49.4521 11.0767
145 nostr.computingcache.com:443 34.0356 -118.442
146 relay.sigit.io 50.4754 12.3683
147 nostr.oxtr.dev:443 50.4754 12.3683
148 relay.angor.io 48.1046 11.6002
149 kotukonostr.onrender.com 37.7775 -122.397
150 zealand-charts-craig-thru.trycloudflare.com 43.6532 -79.3832
151 shu01.shugur.net 21.4902 39.2246
152 relayone.soundhsa.com:443 39.1008 -94.5811
153 relay2.angor.io 48.1046 11.6002
154 nittom.nostr1.com 40.7057 -74.0136
155 nostr.girino.org:443 43.6532 -79.3832
156 nexus.libernet.app 43.6532 -79.3832
157 bitcoiner.social 47.6743 -117.112
158 nostr2.girino.org 43.6532 -79.3832
159 relaisnostr.trivaco.fr 49.4282 10.9796
160 relay.edufeed.org:443 49.4521 11.0767
161 relay.binaryrobot.com 43.6532 -79.3832
162 insta-relay.apps3.slidestr.net 40.4167 -3.70329
163 relay.endfiat.money 59.3327 18.0656
164 nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
165 nrs-01.darkcloudarcade.com:443 39.1008 -94.5811
166 nostr.liberty.fans 36.9104 -89.5875
167 relay.mmwaves.de 48.8575 2.35138
168 relay.inforsupports.com 43.6532 -79.3832
169 dev-nostr.bityacht.io 43.6532 -79.3832
170 yabu.me 35.6092 139.73
171 relay.0xchat.com:443 43.6532 -79.3832
172 relay.agentry.com 42.8864 -78.8784
173 relay.mrmave.work 43.6532 -79.3832
174 nostr.carroarmato0.be 50.914 3.21378
175 nostr.myshosholoza.co.za 52.3913 4.66545
176 nostr.thebiglake.org 32.71 -96.6745
177 relay.kalcafe.xyz 37.3986 -121.964
178 nostr.openhoofd.nl 51.5717 3.70417
179 relayone.soundhsa.com 39.1008 -94.5811
180 relay.bullishbounty.com 43.6532 -79.3832
181 satsage.xyz 37.3986 -121.964
182 relay-testnet.k8s.layer3.news 37.3387 -121.885
183 bridge.tagomago.me 42.3601 -71.0589
184 relay.sigit.io:443 50.4754 12.3683
185 relay.mypathtofire.de 42.8864 -78.8784
186 relay.dreamith.to 43.6532 -79.3832
187 relay.wavlake.com 41.2619 -95.8608
188 relay.lanacoin-eternity.com 40.8302 -74.1299
189 nostr.2b9t.xyz:443 34.0549 -118.243
190 nostr.notribe.net 40.8302 -74.1299
191 relay.nostrdice.com -33.8688 151.209
192 nostr-relay.amethyst.name 39.0067 -77.4291
193 relay.mccormick.cx:443 52.3563 4.95714
194 nostr.4rs.nl 49.0291 8.35696
195 relay.dwadziesciajeden.pl 52.2297 21.0122
196 spookstr2.nostr1.com:443 40.7057 -74.0136
197 relay.vrtmrz.net:443 43.6532 -79.3832
198 relay.minibolt.info:443 43.6532 -79.3832
199 nostrelites.org 41.8781 -87.6298
200 relay.nostar.org 43.6532 -79.3832
201 nostriches.club 43.6532 -79.3832
202 rele.speyhard.fi 51.5072 -0.127586
203 wot.utxo.one 43.6532 -79.3832
204 relay1.orangesync.tech 44.7839 -106.941
205 damus.bostr.online 43.6532 -79.3832
206 relay.islandbitcoin.com:443 12.8498 77.6545
207 relay.mulatta.io 37.5665 126.978
208 strfry.openhoofd.nl 51.5717 3.70417
209 cs-relay.nostrdev.com 50.4754 12.3683
210 nostr.bitcoiner.social 47.6743 -117.112
211 relay.thecryptosquid.com 50.4754 12.3683
212 nostr.computingcache.com 34.0356 -118.442
213 nostr-relay.psfoundation.info 39.0438 -77.4874
214 relay5.bitransfer.org 43.6532 -79.3832
215 relay.damus.io 43.6532 -79.3832
216 strfry.ymir.cloud 43.6532 -79.3832
217 dev.relay.stream 43.6532 -79.3832
218 relay.vrtmrz.net 43.6532 -79.3832
219 myvoiceourstory.org 37.3598 -121.981
220 strfry.openhoofd.nl:443 51.5717 3.70417
221 spookstr2.nostr1.com 40.7057 -74.0136
222 bitcoiner.social:443 47.6743 -117.112
223 aeon.libretechsystems.xyz 55.486 9.86577
224 relayrs.notoshi.win 43.6532 -79.3832
225 nostr.pbfs.io:443 50.4754 12.3683
226 public.crostr.com 43.6532 -79.3832
227 nostr.islandarea.net 35.4669 -97.6473
228 rilo.nostria.app:443 43.6532 -79.3832
229 blossom.gnostr.cloud 43.6532 -79.3832
230 relay.aarpia.com 37.3986 -121.964
231 relay.arx-ccn.com 50.4754 12.3683
232 conduitl2.fly.dev 37.7648 -122.432
233 relay.nostrmap.net 60.1699 24.9384
234 dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
235 nostrelay.circum.space 52.6907 4.8181
236 syb.lol:443 43.6532 -79.3832
237 relay.artx.market 43.6548 -79.3885
238 nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
239 blossom.gnostr.cloud:443 43.6532 -79.3832
240 x.kojira.io:443 43.6532 -79.3832
241 nostr.tac.lol 47.4748 -122.273
242 relay-rpi.edufeed.org 49.4521 11.0767
243 testr.nymble.world 40.8054 -74.0241
244 relay.beginningend.com:443 35.2227 -97.4786
245 relay.openresist.com 43.6532 -79.3832
246 relay.minibolt.info 43.6532 -79.3832
247 nostr.thalheim.io 60.1699 24.9384
248 relay.notoshi.win:443 13.3622 100.983
249 vault.iris.to:443 43.6532 -79.3832
250 top.testrelay.top 43.6532 -79.3832
251 nostrbtc.com 43.6532 -79.3832
252 relay.nearhood.co.uk 51.5072 -0.127586
253 nostr.overmind.lol 43.6532 -79.3832
254 relay.agorist.space 52.3734 4.89406
255 nrs-01.darkcloudarcade.com 39.1008 -94.5811
256 relay.artx.market:443 43.6548 -79.3885
257 nos.lol:443 50.4754 12.3683
258 relay.illuminodes.com 43.6532 -79.3832
259 relay.laantungir.net -19.4692 -42.5315
260 relay.plebchain.club 43.6532 -79.3832
261 nostr.girino.org 43.6532 -79.3832
262 nostrja-kari.heguro.com 43.6532 -79.3832
263 nostrsgp.notribe.net 1.32123 103.695
264 nostr.thalheim.io:443 60.1699 24.9384
265 prl.plus 55.7628 37.5983
266 nostr.carroarmato0.be:443 50.914 3.21378
267 nostr.nodesmap.com 59.3327 18.0656
268 bucket.coracle.social 37.7775 -122.397
269 relay.lightning.pub 39.0438 -77.4874
270 relay.dreamith.to:443 43.6532 -79.3832
271 nostr.na.social:443 43.6532 -79.3832
272 nostr.debate.report 50.1109 8.68213
273 relay2.orangesync.tech 40.7128 -74.006
274 relay.comcomponent.com 43.6532 -79.3832
275 nostr-check.me 43.6532 -79.3832
276 nostr.oxtr.dev 50.4754 12.3683
277 nostr.21crypto.ch 47.5356 8.73209
278 relay.sincensura.org 43.6532 -79.3832
279 relay.internationalright-wing.org -22.5022 -48.7114
280 antiprimal.net 43.6532 -79.3832
281 nostr.rtvslawenia.com 49.4543 11.0746
282 relay.nostr.place 43.6532 -79.3832
283 nostr-kyomu-haskell.onrender.com 37.7775 -122.397
284 nostr.wecsats.io:443 43.6532 -79.3832
285 cs-relay.nostrdev.com:443 50.4754 12.3683
286 nostr.azzamo.net 52.2633 21.0283
287 relay.cypherflow.ai:443 48.8575 2.35138
288 nostr.hifish.org 47.4244 8.57658
289 relay.wavefunc.live:443 41.8781 -87.6298
290 nostr.tac.lol:443 47.4748 -122.273
291 wot.dergigi.com 64.1476 -21.9392
292 nostr.wecsats.io 43.6532 -79.3832
293 relay-dev.satlantis.io 40.8302 -74.1299
294 nostr.sathoarder.com:443 48.5734 7.75211
295 cache.trustr.ing 43.6548 -79.3885
296 relay.directsponsor.net 42.8864 -78.8784
297 strfry.bonsai.com:443 39.0438 -77.4874
298 nostr-01.yakihonne.com 1.32123 103.695
299 relay.satlantis.io 40.8054 -74.0241
300 nostr-2.21crypto.ch:443 47.5356 8.73209
301 offchain.bostr.online 43.6532 -79.3832
302 test.thedude.cloud 50.1109 8.68213
303 relay.mostr.pub:443 43.6532 -79.3832
304 relay.libernet.app:443 43.6532 -79.3832
305 nostr.mom 50.4754 12.3683
306 nostr.rtvslawenia.com:443 49.4543 11.0746
307 relay.plebeian.market 50.1109 8.68213
308 wot.makenomistakes.ca 43.7064 -79.3986
309 dm-test-strfry-discovery.samt.st 43.6532 -79.3832
310 ithurtswhenip.ee 51.5072 -0.127586
311 relay.staging.commonshub.brussels 49.4543 11.0746
312 relay.beginningend.com 35.2227 -97.4786
313 strfry.bonsai.com 39.0438 -77.4874
314 nostrelay.circum.space:443 52.6907 4.8181
315 relay.mostro.network 40.8302 -74.1299
316 dev-relay.nostreon.com 60.1699 24.9384
317 relay.fundstr.me 42.3601 -71.0589
318 relay.mmwaves.de:443 48.8575 2.35138
319 relay.nostu.be 40.4167 -3.70329
320 relay.nostx.io 43.6532 -79.3832
321 nostr.data.haus 50.4754 12.3683
322 relay.lightning.pub:443 39.0438 -77.4874
323 relay.nostr-check.me 43.6532 -79.3832
324 nostr.bitcoiner.social:443 47.6743 -117.112
325 ribo.nostria.app 43.6532 -79.3832
326 offchain.pub 39.1585 -94.5728
327 relay.layer.systems 49.0291 8.35695
328 nostr.dlcdevkit.com 40.0992 -83.1141
329 relay.angor.io:443 48.1046 11.6002
330 relay.mwaters.net 50.9871 2.12554
331 relay.nostriot.com:443 41.5695 -83.9786
332 nostrcity-club.fly.dev 37.7648 -122.432
333 nostr.notribe.net:443 40.8302 -74.1299
334 freelay.sovbit.host 60.1699 24.9384
335 syb.lol 43.6532 -79.3832
336 portal-relay.pareto.space 49.0291 8.35696
337 wot.codingarena.top 50.4754 12.3683
338 nostr-2.21crypto.ch 47.5356 8.73209
339 nostr.hifish.org:443 47.4244 8.57658
340 relay.zone667.com:443 60.1699 24.9384
341 relay.lab.rytswd.com 49.4543 11.0746
342 nas01xanthosnet.synology.me:7778 47.1285 8.74735
343 relay.primal.net 43.6532 -79.3832
344 relay.bitmacro.cloud 43.6532 -79.3832
345 chat-relay.zap-work.com:443 43.6532 -79.3832
346 relay.fountain.fm:443 43.6532 -79.3832
347 ribo.eu.nostria.app 43.6532 -79.3832
348 nexus.libernet.app:443 43.6532 -79.3832
349 nostr.sathoarder.com 48.5734 7.75211
350 nostr-pub.wellorder.net 45.5201 -122.99
351 nostr.snowbla.de 60.1699 24.9384
352 strfry.apps3.slidestr.net 40.4167 -3.70329
353 nostr.spicyz.io:443 43.6532 -79.3832
354 nostr.data.haus:443 50.4754 12.3683
355 nostr.dlcdevkit.com:443 40.0992 -83.1141
356 purplerelay.com:443 43.6532 -79.3832
357 nostr-relay.xbytez.io 50.6924 3.20113
358 relay.nostrmap.net:443 60.1699 24.9384
359 relay.piazza.today relay.ohstr.com 48.122 43.6532 11.589 -79.3832
360 r.0kb.io:443 32.789 -96.7989
361 uncouth-panning-paternal.ngrok-free.dev 40.0992 -83.1141
362 wot.sudocarlos.com 43.6532 -79.3832
363 relay.satmaxt.xyz 43.6532 -79.3832
364 ribo.us.nostria.app:443 43.6532 -79.3832
365 no.str.cr 10.6352 -85.4378
366 relay.notoshi.win 13.3622 100.983
367 nostr.spicyz.io 43.6532 -79.3832
368 relay.veganostr.com 60.1699 24.9384
369 r.0kb.io 32.789 -96.7989
370 relay-testnet.k8s.layer3.news:443 37.3387 -121.885
371 relay.cosmicbolt.net:443 37.3986 -121.964
372 nostr-relay.xbytez.io:443 50.6924 3.20113
373 relay.sharegap.net 43.6532 -79.3832
374 bcast.girino.org 43.6532 -79.3832
375 nostr2.girino.org:443 43.6532 -79.3832
376 relay.cypherflow.ai 48.8575 2.35138
377 nostr-relay.corb.net 38.8353 -104.822
378 relay.staging.plebeian.market 51.5072 -0.127586
379 relay.lanavault.space 60.1699 24.9384
380 relay.wisp.talk 49.4543 11.0746
381 relay.wisp.talk:443 49.4543 11.0746
382 no.str.cr:443 10.6352 -85.4378
383 nostr.hekster.org:443 37.3986 -121.964
384 nostr-01.yakihonne.com:443 1.32123 103.695
385 dm-test-strfry-generic.samt.st 43.6532 -79.3832
386 relay.guggero.org 46.5971 9.59652
387 nostr.twinkle.lol 51.902 7.6657
388 nostr.janx.com 43.6532 -79.3832
389 relay.nostrhub.fr 48.1045 11.6004
390 relay.nostriot.com 41.5695 -83.9786
391 nostr.hekster.org 37.3986 -121.964
392 fanfares.nostr1.com 40.7057 -74.0136
393 nostr-relay.cbrx.io 43.6532 -79.3832
394 relay.edufeed.org 49.4521 11.0767
395 relay.olas.app:443 60.1699 24.9384
396 armada.sharegap.net 43.6532 -79.3832
397 nostr.islandarea.net:443 35.4669 -97.6473
398 relay0.gfcom.info 13.8434 100.363
399 relay.chorus.community 48.5333 10.7
400 nittom.nostr1.com:443 40.7057 -74.0136
401 relay.staging.plebeian.market:443 51.5072 -0.127586
402 chat-relay.zap-work.com 43.6532 -79.3832
403 relay-rpi.edufeed.org:443 49.4521 11.0767
404 relay.lanavault.space:443 60.1699 24.9384
405 relay.binaryrobot.com:443 43.6532 -79.3832
406 nostr.mom:443 50.4754 12.3683
407 relay.bornheimer.app 51.5072 -0.127586
408 treuzkas.branruz.com 48.8575 2.35138
409 nostr-dev.wellorder.net 45.5201 -122.99
410 public.crostr.com:443 43.6532 -79.3832
411 relay.degmods.com 50.4754 12.3683
412 articles.layer3.news:443 37.3387 -121.885
413 nostr.azzamo.net:443 52.2633 21.0283
414 premium.primal.net 43.6532 -79.3832

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

@ -13,9 +13,6 @@ class BitchatApplication : Application() {
override fun onCreate() {
super.onCreate()
// Start the single process-wide power policy before transport components are constructed.
com.bitchat.android.mesh.PowerManager.getInstance(this).start()
// Initialize Tor first so any early network goes over Tor
try {
val torProvider = ArtiTorManager.getInstance()
@ -33,13 +30,6 @@ 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)
@ -48,9 +38,6 @@ 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) { }
@ -66,10 +53,6 @@ class BitchatApplication : Application() {
com.bitchat.android.nostr.GeohashConversationRegistry.initialize(this)
} catch (_: Exception) { }
// Own relay connectivity, selected-channel subscriptions, and presence scheduling at the
// process level so closing the Activity does not disconnect Nostr.
try { com.bitchat.android.nostr.NostrBackgroundRuntime.initialize(this) } catch (_: Exception) { }
// Initialize mesh service preferences
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }

View File

@ -1,7 +1,6 @@
package com.bitchat.android
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.OnBackPressedCallback
@ -21,7 +20,6 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.Lifecycle
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.onboarding.BluetoothCheckScreen
import com.bitchat.android.onboarding.BluetoothStatus
import com.bitchat.android.onboarding.BluetoothStatusManager
@ -82,9 +80,7 @@ class MainActivity : OrientationAwareActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
this.setRecentsScreenshotEnabled(false)
}
// Register receiver for force finish signal from shutdown coordinator
val filter = android.content.IntentFilter(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH)
if (android.os.Build.VERSION.SDK_INT >= 33) {
@ -222,6 +218,7 @@ class MainActivity : OrientationAwareActivity() {
onDispose {
try {
context.unregisterReceiver(receiver)
Log.d("BluetoothStatusUI", "BroadcastReceiver unregistered")
} catch (e: IllegalStateException) {
Log.w("BluetoothStatusUI", "Receiver was not registered")
}
@ -354,7 +351,7 @@ class MainActivity : OrientationAwareActivity() {
when (state) {
OnboardingState.COMPLETE -> {
// App is fully initialized, mesh service is running
android.util.Log.i("MainActivity", "Onboarding completed - app ready")
android.util.Log.d("MainActivity", "Onboarding completed - app ready")
}
OnboardingState.ERROR -> {
android.util.Log.e("MainActivity", "Onboarding error state reached")
@ -364,6 +361,8 @@ class MainActivity : OrientationAwareActivity() {
}
private fun checkOnboardingStatus() {
Log.d("MainActivity", "Checking onboarding status")
lifecycleScope.launch {
// Small delay to show the checking state
delay(500)
@ -377,15 +376,19 @@ class MainActivity : OrientationAwareActivity() {
* Check Bluetooth status and proceed with onboarding flow
*/
private fun checkBluetoothAndProceed() {
// Log.d("MainActivity", "Checking Bluetooth status")
// Check if user has skipped Bluetooth check for this session
if (mainViewModel.isBluetoothCheckSkipped.value) {
Log.d("MainActivity", "Bluetooth check skipped by user, proceeding to location check")
checkLocationAndProceed()
return
}
// For first-time users, skip Bluetooth check and go straight to permissions
// We'll check Bluetooth after permissions are granted
if (permissionManager.isFirstTimeLaunch()) {
Log.d("MainActivity", "First-time launch, skipping Bluetooth check - will check after permissions")
proceedWithPermissionCheck()
return
}
@ -407,6 +410,7 @@ class MainActivity : OrientationAwareActivity() {
}
BluetoothStatus.DISABLED -> {
// Show Bluetooth enable screen (should have permissions as existing user)
Log.d("MainActivity", "Bluetooth disabled, showing enable screen")
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
mainViewModel.updateBluetoothLoading(false)
}
@ -423,14 +427,16 @@ class MainActivity : OrientationAwareActivity() {
* Proceed with permission checking
*/
private fun proceedWithPermissionCheck() {
Log.d("MainActivity", "Proceeding with permission check")
lifecycleScope.launch {
delay(200) // Small delay for smooth transition
if (permissionManager.isFirstTimeLaunch()) {
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
} else if (permissionManager.getUnrequestedOptionalPermissions().isNotEmpty()) {
Log.d("MainActivity", "First time launch, showing permission explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
} else if (permissionManager.areRequiredPermissionsGranted()) {
Log.d("MainActivity", "Existing user with required permissions")
if (permissionManager.needsBackgroundLocationPermission() &&
!permissionManager.isBackgroundLocationGranted() &&
!com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity)
@ -441,6 +447,7 @@ class MainActivity : OrientationAwareActivity() {
initializeApp()
}
} else {
Log.d("MainActivity", "Existing user missing permissions, showing explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
}
}
@ -450,6 +457,7 @@ class MainActivity : OrientationAwareActivity() {
* Handle Bluetooth enabled callback
*/
private fun handleBluetoothEnabled() {
Log.d("MainActivity", "Bluetooth enabled by user")
mainViewModel.updateBluetoothLoading(false)
mainViewModel.updateBluetoothStatus(BluetoothStatus.ENABLED)
checkLocationAndProceed()
@ -459,9 +467,12 @@ class MainActivity : OrientationAwareActivity() {
* Check Location services status and proceed with onboarding flow
*/
private fun checkLocationAndProceed() {
Log.d("MainActivity", "Checking location services status")
// For first-time users, skip location check and go straight to permissions
// We'll check location after permissions are granted
if (permissionManager.isFirstTimeLaunch()) {
Log.d("MainActivity", "First-time launch, skipping location check - will check after permissions")
proceedWithPermissionCheck()
return
}
@ -477,6 +488,7 @@ class MainActivity : OrientationAwareActivity() {
}
LocationStatus.DISABLED -> {
// Show location enable screen (should have permissions as existing user)
Log.d("MainActivity", "Location services disabled, showing enable screen")
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
mainViewModel.updateLocationLoading(false)
}
@ -493,6 +505,7 @@ class MainActivity : OrientationAwareActivity() {
* Handle Location enabled callback
*/
private fun handleLocationEnabled() {
Log.d("MainActivity", "Location services enabled by user")
mainViewModel.updateLocationLoading(false)
mainViewModel.updateLocationStatus(LocationStatus.ENABLED)
// Ensure Wi-Fi Aware starts now that location is enabled
@ -538,10 +551,12 @@ class MainActivity : OrientationAwareActivity() {
message.contains("Permission") && permissionManager.isFirstTimeLaunch() -> {
// During first-time onboarding, if Bluetooth enable fails due to permissions,
// proceed to permission explanation screen where user will grant permissions first
Log.d("MainActivity", "Bluetooth enable requires permissions, proceeding to permission explanation")
proceedWithPermissionCheck()
}
message.contains("Permission") -> {
// For existing users, redirect to permission explanation to grant missing permissions
Log.d("MainActivity", "Bluetooth enable requires permissions, showing permission explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
}
else -> {
@ -552,6 +567,8 @@ class MainActivity : OrientationAwareActivity() {
}
private fun handleOnboardingComplete() {
Log.d("MainActivity", "Onboarding completed, checking Bluetooth and Location before initializing app")
// After permissions are granted, re-check Bluetooth, Location, and Battery Optimization status
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
val currentLocationStatus = locationStatusManager.checkLocationStatus()
@ -565,24 +582,28 @@ class MainActivity : OrientationAwareActivity() {
when {
bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> {
// Bluetooth still disabled, but now we have permissions to enable it
Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
mainViewModel.updateBluetoothLoading(false)
}
currentLocationStatus != LocationStatus.ENABLED -> {
// Location services still disabled, but now we have permissions to enable it
Log.d("MainActivity", "Permissions granted, but Location services still disabled. Showing Location enable screen.")
mainViewModel.updateLocationStatus(currentLocationStatus)
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
mainViewModel.updateLocationLoading(false)
}
currentBatteryOptimizationStatus == BatteryOptimizationStatus.ENABLED -> {
// Battery optimization still enabled, show battery optimization screen
android.util.Log.d("MainActivity", "Permissions granted, but battery optimization still enabled. Showing battery optimization screen.")
mainViewModel.updateBatteryOptimizationStatus(currentBatteryOptimizationStatus)
mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK)
mainViewModel.updateBatteryOptimizationLoading(false)
}
else -> {
// Both are enabled, proceed to app initialization
Log.d("MainActivity", "Both Bluetooth and Location services are enabled, proceeding to initialization")
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
initializeApp()
}
@ -615,15 +636,19 @@ class MainActivity : OrientationAwareActivity() {
* Check Battery Optimization status and proceed with onboarding flow
*/
private fun checkBatteryOptimizationAndProceed() {
android.util.Log.d("MainActivity", "Checking battery optimization status")
// For first-time users, skip battery optimization check and go straight to permissions
// We'll check battery optimization after permissions are granted
if (permissionManager.isFirstTimeLaunch()) {
android.util.Log.d("MainActivity", "First-time launch, skipping battery optimization check - will check after permissions")
proceedWithPermissionCheck()
return
}
// Check if user has previously skipped battery optimization
if (BatteryOptimizationPreferenceManager.isSkipped(this)) {
android.util.Log.d("MainActivity", "User previously skipped battery optimization, proceeding to permissions")
proceedWithPermissionCheck()
return
}
@ -644,6 +669,7 @@ class MainActivity : OrientationAwareActivity() {
}
BatteryOptimizationStatus.ENABLED -> {
// Show battery optimization disable screen
android.util.Log.d("MainActivity", "Battery optimization enabled, showing disable screen")
mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK)
mainViewModel.updateBatteryOptimizationLoading(false)
}
@ -654,6 +680,7 @@ class MainActivity : OrientationAwareActivity() {
* Handle Battery Optimization disabled callback
*/
private fun handleBatteryOptimizationDisabled() {
android.util.Log.d("MainActivity", "Battery optimization disabled by user")
mainViewModel.updateBatteryOptimizationLoading(false)
mainViewModel.updateBatteryOptimizationStatus(BatteryOptimizationStatus.DISABLED)
proceedWithPermissionCheck()
@ -677,14 +704,19 @@ class MainActivity : OrientationAwareActivity() {
}
private fun initializeApp() {
Log.d("MainActivity", "Starting app initialization")
lifecycleScope.launch {
try {
// Initialize the app with a proper delay to ensure Bluetooth stack is ready
// This solves the issue where app needs restart to work on first install
delay(1000) // Give the system time to process permission grants
Log.d("MainActivity", "Permissions verified, initializing chat system")
// Initialize PoW preferences early in the initialization process
PoWPreferenceManager.init(this@MainActivity)
Log.d("MainActivity", "PoW preferences initialized")
// Initialize Location Notes Manager (extracted to separate file)
com.bitchat.android.nostr.LocationNotesInitializer.initialize(this@MainActivity)
@ -701,14 +733,16 @@ class MainActivity : OrientationAwareActivity() {
unifiedMeshService.delegate = chatViewModel
unifiedMeshService.startServices()
startMeshForegroundServiceBestEffort()
Log.d("MainActivity", "Mesh service started successfully")
// Handle any notification intent
handleNotificationIntent(intent)
handleVerificationIntent(intent)
// Small delay to ensure mesh service is fully initialized
delay(500)
Log.i("MainActivity", "App initialization complete")
Log.d("MainActivity", "App initialization complete")
mainViewModel.updateOnboardingState(OnboardingState.COMPLETE)
} catch (e: Exception) {
Log.e("MainActivity", "Failed to initialize app", e)
@ -746,9 +780,6 @@ class MainActivity : OrientationAwareActivity() {
override fun onResume() {
super.onResume()
// Revoke stale live-location work before any resumed UI can use cached channels.
LocationChannelManager.getInstance(applicationContext).syncPermissionState()
// Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation
@ -823,7 +854,7 @@ class MainActivity : OrientationAwareActivity() {
val geohash = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_GEOHASH)
if (geohash != null) {
Log.d("MainActivity", "Opening geohash chat from notification")
Log.d("MainActivity", "Opening geohash chat #$geohash from notification")
// Switch to the geohash channel - create appropriate geohash channel level
val level = when (geohash.length) {
@ -868,6 +899,7 @@ class MainActivity : OrientationAwareActivity() {
// Cleanup location status manager
try {
locationStatusManager.cleanup()
Log.d("MainActivity", "Location status manager cleaned up successfully")
} catch (e: Exception) {
Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
}

View File

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

View File

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

View File

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

View File

@ -10,10 +10,9 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.CompositionLocalProvider
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.core.ui.component.button.CloseButton
@OptIn(ExperimentalMaterial3Api::class)
@ -31,9 +30,8 @@ fun BitchatSheetTopBar(
navigationIcon = { navigationIcon?.invoke() },
actions = {
actions()
val dismiss = LocalSheetDismiss.current
CloseButton(
onClick = { dismiss?.invoke() ?: onClose() },
onClick = onClose,
modifier = Modifier.padding(horizontal = 16.dp)
)
},
@ -61,9 +59,8 @@ fun BitchatSheetCenterTopBar(
navigationIcon = { navigationIcon?.invoke() },
actions = {
actions()
val dismiss = LocalSheetDismiss.current
CloseButton(
onClick = { dismiss?.invoke() ?: onClose() },
onClick = onClose,
modifier = Modifier.padding(horizontal = 16.dp)
)
},
@ -83,7 +80,7 @@ fun BitchatSheetTitle(text: String) {
text = text,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
fontFamily = BitchatFontFamily
fontFamily = FontFamily.Monospace
)
)
}

View File

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

View File

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

View File

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

View File

@ -7,9 +7,6 @@ import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.bitchat.android.noise.NoiseEncryptionService
import com.bitchat.android.noise.NoiseHandshakeProcessingResult
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoiseDecryptionResult
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters
@ -193,13 +190,6 @@ open class EncryptionService(private val context: Context) {
}
return encrypted
}
@Throws(Exception::class)
fun encryptForSession(
data: ByteArray,
peerID: String,
expectedSession: AuthenticatedNoiseSession
): ByteArray = noiseService.encryptForSession(data, peerID, expectedSession)
/**
* Decrypt data from a specific peer using Noise transport encryption
@ -212,12 +202,6 @@ open class EncryptionService(private val context: Context) {
}
return decrypted
}
@Throws(Exception::class)
fun decryptWithSession(data: ByteArray, peerID: String): NoiseDecryptionResult {
return noiseService.decryptWithSession(data, peerID)
?: throw Exception("Failed generation-bound decryption from $peerID")
}
/**
* Sign data using our static identity key
@ -270,25 +254,6 @@ open class EncryptionService(private val context: Context) {
fun getPeerFingerprint(peerID: String): String? {
return noiseService.getPeerFingerprint(peerID)
}
/**
* Return the remote static key authenticated by the live Noise handshake.
* This deliberately bypasses announcement and PeerFingerprintManager
* caches; callers making downgrade decisions must bind to live channel
* authentication, not a self-certified identity payload.
*/
fun getAuthenticatedRemoteStaticKey(peerID: String): ByteArray? {
return getAuthenticatedSession(peerID)?.remoteStaticKey?.copyOf()
}
fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? =
noiseService.getAuthenticatedSession(peerID)
fun withAuthenticatedSession(
peerID: String,
expectedSession: AuthenticatedNoiseSession,
action: () -> Boolean
): Boolean = noiseService.withAuthenticatedSession(peerID, expectedSession, action)
/**
* Get current peer ID for a fingerprint (for peer ID rotation)
@ -300,9 +265,9 @@ open class EncryptionService(private val context: Context) {
/**
* Initiate a Noise handshake with a peer
*/
fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? {
fun initiateHandshake(peerID: String): ByteArray? {
Log.d(TAG, "🤝 Initiating Noise handshake with $peerID")
return noiseService.initiateHandshake(peerID, replaceEstablished)
return noiseService.initiateHandshake(peerID)
}
/**
@ -312,24 +277,11 @@ open class EncryptionService(private val context: Context) {
Log.d(TAG, "🤝 Processing handshake message from $peerID")
return noiseService.processHandshakeMessage(data, peerID)
}
/**
* Process one Noise handshake frame while preserving whether this exact call authenticated a
* new session. Unlike the response-only compatibility API, binding failures are propagated.
*/
@Throws(Exception::class)
open fun processHandshakeMessageWithResult(
data: ByteArray,
peerID: String
): NoiseHandshakeProcessingResult {
Log.d(TAG, "🤝 Processing typed handshake message from $peerID")
return noiseService.processHandshakeMessageWithResult(data, peerID)
}
/**
* Remove a peer session (called when peer disconnects)
*/
open fun removePeer(peerID: String) {
fun removePeer(peerID: String) {
establishedSessions.remove(peerID)
noiseService.removePeer(peerID)
onSessionLost?.invoke(peerID)

View File

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

View File

@ -2,15 +2,15 @@ package com.bitchat.android.favorites
import android.content.Context
import android.util.Log
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.services.ContactIdentityResolver
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.util.*
/**
* Bridging Noise and Nostr favorites
* Direct port from iOS FavoritesPersistenceService.swift, with Android-specific
* peerID (16-hex) -> npub indexing for Nostr DM routing.
*/
data class FavoriteRelationship(
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes)
@ -48,25 +48,6 @@ 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()
@ -103,6 +84,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
private val stateManager = SecureIdentityStateManager(context)
private val gson = Gson()
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseHex -> relationship
// NEW: Index by current mesh peerID (16-hex) for direct lookup when sending Nostr DMs from mesh context
private val peerIdIndex = mutableMapOf<String, String>() // peerID (lowercase 16-hex) -> npub
private val listeners = mutableListOf<FavoritesChangeListener>()
@ -113,55 +95,35 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Get favorite status for Noise public key */
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
return favorites[keyHex]
}
/** Get favorite status for a mesh peer ID or full Noise public key hex. */
/** Get favorite status for 16-hex peerID (by noiseHex prefix match) */
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
val pid = peerID.trim().lowercase()
if (ContactIdentityResolver.isNoiseKeyHex(pid)) {
return favorites[pid]
val pid = peerID.lowercase()
for ((_, relationship) in favorites) {
val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
if (noiseKeyHex.startsWith(pid)) return relationship
}
ContactIdentityResolver.fingerprintFromContactConversationId(pid)?.let { fingerprint ->
return favorites.values.firstOrNull { relationship ->
ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey)
.equals(fingerprint, ignoreCase = true)
}
}
if (ContactIdentityResolver.isMeshPeerId(pid)) {
peerIdIndex[pid]?.let { indexedNpub ->
findNoiseKey(indexedNpub)?.let { return getFavoriteStatus(it) }
}
return favorites.values.firstOrNull { relationship ->
ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey) == pid
}
}
return null
}
/** Update Nostr public key for a peer (indexed by Noise key) */
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
?.let { ContactIdentityResolver.npubFromHex(it) }
?: nostrPubkey
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val existing = favorites[keyHex]
if (existing != null) {
val updated = existing.copy(
peerNostrPublicKey = normalizedNpub,
peerNostrPublicKey = nostrPubkey,
lastUpdated = Date()
)
favorites[keyHex] = updated
} else {
val relationship = FavoriteRelationship(
peerNoisePublicKey = noisePublicKey,
peerNostrPublicKey = normalizedNpub,
peerNostrPublicKey = nostrPubkey,
peerNickname = "Unknown",
isFavorite = false,
theyFavoritedUs = false,
@ -177,16 +139,12 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** Update Nostr pubkey for a specific mesh peerID. */
/** NEW: Update Nostr pubkey for specific mesh peerID (16-hex). */
fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) {
val pid = peerID.trim().lowercase()
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
?.let { ContactIdentityResolver.npubFromHex(it) }
?: nostrPubkey
if (ContactIdentityResolver.isMeshPeerId(pid)) {
peerIdIndex[pid] = normalizedNpub
val pid = peerID.lowercase()
if (pid.length == 16 && pid.matches(Regex("^[0-9a-f]+$"))) {
peerIdIndex[pid] = nostrPubkey
savePeerIdIndex()
notifyChanged(pid)
Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}")
} else {
Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID")
@ -194,32 +152,33 @@ class FavoritesPersistenceService private constructor(private val context: Conte
}
/** Resolve Nostr pubkey via current peerID mapping or stored Noise identity. */
/** NEW: Resolve Nostr pubkey via current peerID mapping (fast path). */
fun findNostrPubkeyForPeerID(peerID: String): String? {
val pid = peerID.trim().lowercase()
return peerIdIndex[pid] ?: getFavoriteStatus(pid)?.peerNostrPublicKey
return peerIdIndex[peerID.lowercase()]
}
/** Resolve mesh peerID for a given Nostr pubkey (npub or hex). */
/** NEW: Resolve peerID (16-hex) for a given Nostr pubkey (npub or hex). */
fun findPeerIDForNostrPubkey(nostrPubkey: String): String? {
val targetHex = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) ?: return null
peerIdIndex.entries.firstOrNull { (_, stored) ->
ContactIdentityResolver.nostrPubkeyHex(stored) == targetHex
}?.let { return it.key }
favorites.values.firstOrNull { relationship ->
relationship.peerNostrPublicKey?.let { ContactIdentityResolver.nostrPubkeyHex(it) } == targetHex
}?.let { relationship ->
return ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey)
// First, try direct match in peerIdIndex (values are stored as npub strings)
peerIdIndex.entries.firstOrNull { it.value.equals(nostrPubkey, ignoreCase = true) }?.let { return it.key }
// Attempt legacy mapping via favorites Noise key association
val targetHex = normalizeNostrKeyToHex(nostrPubkey)
if (targetHex != null) {
// Find relationship with matching nostr pubkey (normalized to hex) and then try to map to current peerID via noise key prefix
val rel = favorites.values.firstOrNull { it.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex }
if (rel != null) {
val noiseHex = rel.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
// Return 16-hex prefix as best-effort if no explicit mapping exists
return noiseHex.take(16)
}
}
return null
}
/** Update favorite status */
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val existing = favorites[keyHex]
@ -251,20 +210,24 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Update peer favorited-us flag */
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey)
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val existing = favorites[keyHex]
val updated = existing.withPeerFavoritedUs(noisePublicKey, theyFavoritedUs)
favorites[keyHex] = updated
saveFavorites()
notifyChanged(keyHex)
if (existing != null) {
val updated = existing.copy(
theyFavoritedUs = theyFavoritedUs,
lastUpdated = Date()
)
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 }
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
fun getAllRelationships(): List<FavoriteRelationship> = favorites.values.toList()
fun clearAllFavorites() {
favorites.clear()
@ -277,15 +240,15 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Find Noise key by Nostr pubkey */
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
val targetHex = ContactIdentityResolver.nostrPubkeyHex(forNostrPubkey) ?: return null
val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null
return favorites.values.firstOrNull { rel ->
rel.peerNostrPublicKey?.let { stored -> ContactIdentityResolver.nostrPubkeyHex(stored) } == targetHex
rel.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex
}?.peerNoisePublicKey
}
/** Find Nostr pubkey by Noise key */
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey)
val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
return favorites[keyHex]?.peerNostrPublicKey
}
@ -329,12 +292,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
val type = object : TypeToken<Map<String, String>>() {}.type
val data: Map<String, String> = gson.fromJson(json, type)
peerIdIndex.clear()
data.forEach { (peerID, npub) ->
val normalizedPeerID = peerID.lowercase()
if (ContactIdentityResolver.isMeshPeerId(normalizedPeerID)) {
peerIdIndex[normalizedPeerID] = npub
}
}
peerIdIndex.putAll(data)
Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings")
}
} catch (e: Exception) {
@ -360,7 +318,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte
synchronized(listeners) { listeners.remove(listener) }
}
private fun notifyChanged(noiseKeyHex: String) {
runCatching { AppStateStore.canonicalizePrivateChats() }
val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { runCatching { it.onFavoriteChanged(noiseKeyHex) } }
}
@ -368,6 +325,14 @@ class FavoritesPersistenceService private constructor(private val context: Conte
val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { runCatching { it.onAllCleared() } }
}
/** Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex */
private fun normalizeNostrKeyToHex(value: String): String? = try {
if (value.startsWith("npub1")) {
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value)
if (hrp != "npub") null else data.joinToString("") { "%02x".format(it) }
} else value.lowercase()
} catch (_: Exception) { null }
}
/** Serializable data for JSON storage */
@ -383,7 +348,7 @@ private data class FavoriteRelationshipData(
companion object {
fun fromFavoriteRelationship(relationship: FavoriteRelationship): FavoriteRelationshipData {
return FavoriteRelationshipData(
peerNoisePublicKeyHex = ContactIdentityResolver.noiseKeyHex(relationship.peerNoisePublicKey),
peerNoisePublicKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) },
peerNostrPublicKey = relationship.peerNostrPublicKey,
peerNickname = relationship.peerNickname,
isFavorite = relationship.isFavorite,
@ -395,7 +360,7 @@ private data class FavoriteRelationshipData(
}
fun toFavoriteRelationship(): FavoriteRelationship {
val noiseKeyBytes = ContactIdentityResolver.bytesFromHex(peerNoisePublicKeyHex) ?: ByteArray(0)
val noiseKeyBytes = peerNoisePublicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
return FavoriteRelationship(
peerNoisePublicKey = noiseKeyBytes,
peerNostrPublicKey = peerNostrPublicKey,

View File

@ -5,8 +5,6 @@ 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
@ -325,80 +323,4 @@ 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

@ -1,362 +0,0 @@
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

@ -1,478 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,156 +0,0 @@
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

@ -1,220 +0,0 @@
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,38 +18,17 @@ import java.util.Locale
* Simple MediaRecorder wrapper that records to M4A (AAC) for wide compatibility.
* The resulting file has MIME audio/mp4.
*/
class VoiceRecorder(
private val context: Context,
private val liveTarget: LiveVoiceTarget? = null
) {
class VoiceRecorder(private val context: Context) {
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"
@ -77,19 +56,13 @@ class VoiceRecorder(
fun pollAmplitude(): Int {
return try {
val amp = liveCapture?.pollAmplitude() ?: recorder?.maxAmplitude ?: 0
val amp = recorder?.maxAmplitude ?: 0
_amplitude.value = amp
amp
} catch (_: Exception) { 0 }
}
fun stop(canceled: Boolean = false): File? {
liveCapture?.let { capture ->
val file = capture.stop(canceled)
liveCapture = null
outFile = null
return file
}
fun stop(): File? {
try {
recorder?.apply {
try { stop() } catch (_: Exception) {}
@ -100,10 +73,6 @@ class VoiceRecorder(
val f = outFile
recorder = null
outFile = null
if (canceled) {
f?.delete()
return null
}
return f
}
}

View File

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

View File

@ -9,9 +9,8 @@ import android.os.Looper
import android.util.Log
import androidx.core.app.ActivityCompat
import com.google.android.gms.location.*
import com.google.android.gms.tasks.CancellationTokenSource
internal class FusedLocationProvider(private val context: Context) : LocationProvider {
class FusedLocationProvider(private val context: Context) : LocationProvider {
companion object {
private const val TAG = "FusedLocationProvider"
@ -21,13 +20,10 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro
// Map to keep track of callbacks to remove them later
private val activeCallbacks = mutableMapOf<(Location) -> Unit, LocationCallback>()
private val activeCurrentLocationRequests = mutableSetOf<CancellationTokenSource>()
private fun hasLocationPermission(): Boolean {
return LiveLocationPrivacyGate.isEnabled &&
(ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
)
}
@SuppressLint("MissingPermission")
@ -40,14 +36,14 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro
try {
fusedLocationClient.lastLocation
.addOnSuccessListener { location ->
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
callback(location)
}
.addOnFailureListener { e ->
Log.e(TAG, "Error getting last-known fused location")
Log.e(TAG, "Error getting last known fused location: ${e.message}")
callback(null)
}
} catch (e: Exception) {
Log.e(TAG, "Exception getting last-known fused location")
Log.e(TAG, "Exception getting last known fused location: ${e.message}")
callback(null)
}
}
@ -64,27 +60,17 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro
.setPriority(Priority.PRIORITY_HIGH_ACCURACY)
.setDurationMillis(30000)
.build()
val cancellation = CancellationTokenSource()
synchronized(activeCurrentLocationRequests) {
activeCurrentLocationRequests.add(cancellation)
}
fusedLocationClient.getCurrentLocation(request, cancellation.token)
fusedLocationClient.getCurrentLocation(request, null)
.addOnSuccessListener { location ->
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
callback(location)
}
.addOnFailureListener { e ->
Log.e(TAG, "Error getting fresh fused location")
Log.e(TAG, "Error getting fresh fused location: ${e.message}")
callback(null)
}
.addOnCompleteListener {
synchronized(activeCurrentLocationRequests) {
activeCurrentLocationRequests.remove(cancellation)
}
}
} catch (e: Exception) {
Log.e(TAG, "Exception getting fresh fused location")
Log.e(TAG, "Exception getting fresh fused location: ${e.message}")
callback(null)
}
}
@ -105,9 +91,7 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro
val locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
if (LiveLocationPrivacyGate.isEnabled) {
result.lastLocation?.let { callback(it) }
}
result.lastLocation?.let { callback(it) }
}
}
@ -123,7 +107,7 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro
Log.d(TAG, "Registered fused updates")
} catch (e: Exception) {
Log.e(TAG, "Error requesting fused updates")
Log.e(TAG, "Error requesting fused updates: ${e.message}")
}
}
@ -138,25 +122,21 @@ internal class FusedLocationProvider(private val context: Context) : LocationPro
Log.d(TAG, "Removed fused updates")
}
} catch (e: Exception) {
Log.e(TAG, "Error removing fused updates")
Log.e(TAG, "Error removing fused updates: ${e.message}")
}
}
override fun cancel() {
try {
synchronized(activeCallbacks) {
for ((_, locationCallback) in activeCallbacks) {
for ((callback, locationCallback) in activeCallbacks) {
fusedLocationClient.removeLocationUpdates(locationCallback)
}
activeCallbacks.clear()
}
synchronized(activeCurrentLocationRequests) {
activeCurrentLocationRequests.forEach { it.cancel() }
activeCurrentLocationRequests.clear()
}
Log.d(TAG, "Cancelled all fused updates")
} catch (e: Exception) {
Log.e(TAG, "Error cancelling fused provider")
Log.e(TAG, "Error cancelling fused provider: ${e.message}")
}
}
}

View File

@ -9,10 +9,5 @@ interface GeocoderProvider {
/**
* Get a list of Address objects from latitude and longitude.
*/
suspend fun getFromLocation(
latitude: Double,
longitude: Double,
maxResults: Int,
liveLocationToken: Long? = null
): List<Address>
suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address>
}

View File

@ -112,7 +112,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
_bookmarks.value = ordered
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load bookmarks")
Log.e(TAG, "Failed to load bookmarks: ${e.message}")
}
try {
val namesJson = prefs.getString(NAMES_STORE_KEY, null)
@ -122,7 +122,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
_bookmarkNames.value = dict
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load bookmark names")
Log.e(TAG, "Failed to load bookmark names: ${e.message}")
}
}
@ -155,7 +155,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
resolving.clear()
Log.i(TAG, "Cleared all geohash bookmarks and names")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear geohash bookmarks")
Log.e(TAG, "Failed to clear geohash bookmarks: ${e.message}")
}
}
@ -213,7 +213,7 @@ class GeohashBookmarksStore private constructor(private val context: Context) {
persistNames(current)
}
} catch (e: Exception) {
Log.w(TAG, "Bookmark name resolution failed")
Log.w(TAG, "Name resolution failed for #$gh: ${e.message}")
} finally {
resolving.remove(gh)
}

View File

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

View File

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

View File

@ -4,14 +4,16 @@ import android.Manifest
import android.content.Context
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.Geocoder
import android.location.Location
import android.location.LocationManager
import android.os.Bundle
import android.util.Log
import androidx.core.app.ActivityCompat
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.bitchat.android.nostr.NostrIdentityBridge
import kotlinx.coroutines.*
import java.util.*
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import kotlinx.coroutines.flow.MutableStateFlow
@ -45,19 +47,13 @@ class LocationChannelManager private constructor(private val context: Context) {
AUTHORIZED
}
enum class LocationSelectionSource {
NEARBY,
MANUAL
}
private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private val locationProvider: LocationProvider
private val geocoderProvider: GeocoderProvider = GeocoderFactory.get(context)
private var lastLocation: Location? = null
private var geocodingJob: Job? = null
private val gson = Gson()
private var dataManager: com.bitchat.android.ui.DataManager? = null
private var selectedLocationSource: LocationSelectionSource? = null
private var activeLocationUpdateCallback: ((Location) -> Unit)? = null
private fun checkSystemLocationEnabled(): Boolean {
return try {
@ -74,13 +70,14 @@ class LocationChannelManager private constructor(private val context: Context) {
val isEnabled = checkSystemLocationEnabled()
Log.d(TAG, "System location state changed: $isEnabled")
_systemLocationEnabled.value = isEnabled
if (!isEnabled) {
clearLiveLocationState()
}
}
}
}
private val locationUpdateCallback: (Location) -> Unit = { location ->
onLocationUpdated(location)
}
// Published state for UI bindings (matching iOS @Published properties)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
@ -102,7 +99,8 @@ class LocationChannelManager private constructor(private val context: Context) {
private val _isLoadingLocation = MutableStateFlow(false)
val isLoadingLocation: StateFlow<Boolean> = _isLoadingLocation
val locationServicesEnabled: StateFlow<Boolean> = LiveLocationPrivacyGate.enabled
private val _locationServicesEnabled = MutableStateFlow(false)
val locationServicesEnabled: StateFlow<Boolean> = _locationServicesEnabled
private val _systemLocationEnabled = MutableStateFlow(checkSystemLocationEnabled())
val systemLocationEnabled: StateFlow<Boolean> = _systemLocationEnabled
@ -129,14 +127,12 @@ class LocationChannelManager private constructor(private val context: Context) {
Log.i(TAG, "Using SystemLocationProvider (Native LocationManager)")
SystemLocationProvider(context)
}
LiveLocationPrivacyGate.addRevocationListener(::cancelLiveLocationWork)
checkAndSyncPermission()
// Initialize DataManager and load persisted settings
dataManager = com.bitchat.android.ui.DataManager(context)
loadLocationServicesState()
syncPermissionState()
if (!_systemLocationEnabled.value) clearLiveLocationState()
loadPersistedChannelSelection()
loadLocationServicesState()
// Register for system location changes
context.registerReceiver(locationStateReceiver, IntentFilter(LocationManager.PROVIDERS_CHANGED_ACTION))
@ -149,13 +145,21 @@ class LocationChannelManager private constructor(private val context: Context) {
* UNIFIED: Only requests location if location services are enabled by user
*/
fun enableLocationChannels() {
if (!LiveLocationPrivacyGate.isEnabled || !_systemLocationEnabled.value) {
Log.d(TAG, "enableLocationChannels() called")
if (!_locationServicesEnabled.value || !_systemLocationEnabled.value) {
Log.w(TAG, "Location services disabled (app or system) - not requesting location")
return
}
if (syncPermissionState() == PermissionState.AUTHORIZED) {
if (getCurrentPermissionStatus() == PermissionState.AUTHORIZED) {
Log.d(TAG, "Permission authorized - requesting location")
_permissionState.value = PermissionState.AUTHORIZED
requestOneShotLocation()
} else {
Log.d(TAG, "Permission not granted")
_permissionState.value = PermissionState.DENIED
}
}
@ -163,7 +167,7 @@ class LocationChannelManager private constructor(private val context: Context) {
* Refresh available channels from current location
*/
fun refreshChannels() {
if (syncPermissionState() == PermissionState.AUTHORIZED && isLocationServicesEnabled()) {
if (_permissionState.value == PermissionState.AUTHORIZED && isLocationServicesEnabled()) {
requestOneShotLocation()
}
}
@ -173,7 +177,9 @@ class LocationChannelManager private constructor(private val context: Context) {
* Uses requestLocationUpdates for continuous updates, plus a one-shot to prime state immediately
*/
fun beginLiveRefresh(interval: Long = 5000L) {
if (syncPermissionState() != PermissionState.AUTHORIZED) {
Log.d(TAG, "Beginning live refresh (continuous updates)")
if (_permissionState.value != PermissionState.AUTHORIZED) {
Log.w(TAG, "Cannot start live refresh - permission not authorized")
return
}
@ -183,28 +189,12 @@ class LocationChannelManager private constructor(private val context: Context) {
return
}
endLiveRefresh()
LiveLocationPrivacyGate.resumeAccess()
val token = LiveLocationPrivacyGate.captureToken() ?: return
val callback: (Location) -> Unit = { location ->
if (canUseLiveLocation(token)) {
onLocationUpdated(location, token)
}
}
activeLocationUpdateCallback = callback
// Register for continuous updates from available provider
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.requestLocationUpdates(
intervalMs = interval,
minDistanceMeters = 5f,
callback = callback
)
}
if (!started) {
activeLocationUpdateCallback = null
return
}
locationProvider.requestLocationUpdates(
intervalMs = interval,
minDistanceMeters = 5f,
callback = locationUpdateCallback
)
// Prime state immediately with last known / current location
requestOneShotLocation()
@ -214,56 +204,57 @@ class LocationChannelManager private constructor(private val context: Context) {
* Stop periodic refreshes when selector UI is dismissed
*/
fun endLiveRefresh() {
activeLocationUpdateCallback?.let(locationProvider::removeLocationUpdates)
activeLocationUpdateCallback = null
Log.d(TAG, "Ending live refresh")
locationProvider.removeLocationUpdates(locationUpdateCallback)
}
/**
* Generic selection is intentionally treated as manual. GPS-derived selections must use
* [selectNearby] so their provenance can be revoked when live location is disabled.
* Select a channel
*/
fun select(channel: ChannelID) {
when (channel) {
ChannelID.Mesh -> selectInternal(ChannelID.Mesh, source = null, teleported = false)
is ChannelID.Location -> selectManual(channel.channel)
Log.d(TAG, "Selected channel: ${channel.displayName}")
// Use synchronous set to avoid race with background recomputation
_selectedChannel.value = channel
saveChannelSelection(channel)
// Immediately recompute teleported status against the latest known location
lastLocation?.let { location ->
when (channel) {
is ChannelID.Mesh -> {
_teleported.value = false
}
is ChannelID.Location -> {
val currentGeohash = Geohash.encode(
latitude = location.latitude,
longitude = location.longitude,
precision = channel.channel.level.precision
)
val isTeleportedNow = currentGeohash != channel.channel.geohash
_teleported.value = isTeleportedNow
Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})")
}
}
}
}
fun selectNearby(channel: GeohashChannel): Boolean {
val isCurrentNearbyChannel = _availableChannels.value.contains(channel)
if (!isCurrentNearbyChannel || !isLocationServicesEnabled() ||
syncPermissionState() != PermissionState.AUTHORIZED
) {
Log.w(TAG, "Blocked nearby channel selection without live-location access")
return false
}
selectInternal(
ChannelID.Location(channel),
source = LocationSelectionSource.NEARBY,
teleported = false
)
return true
}
fun selectManual(channel: GeohashChannel, teleported: Boolean = true) {
selectInternal(
ChannelID.Location(channel),
source = LocationSelectionSource.MANUAL,
teleported = teleported || !LiveLocationPrivacyGate.isEnabled
)
/**
* Set teleported status (for manual geohash teleportation)
*/
fun setTeleported(teleported: Boolean) {
Log.d(TAG, "Setting teleported status: $teleported")
_teleported.value = teleported
}
/**
* Enable location services (user-controlled toggle)
*/
fun enableLocationServices() {
if (!LiveLocationPrivacyGate.isEnabled) {
LiveLocationPrivacyGate.update(true)
saveLocationServicesState(true)
}
Log.d(TAG, "enableLocationServices() called by user")
_locationServicesEnabled.value = true
saveLocationServicesState(true)
// If we have permission and system location is on, start location operations
if (syncPermissionState() == PermissionState.AUTHORIZED && systemLocationEnabled.value) {
if (_permissionState.value == PermissionState.AUTHORIZED && systemLocationEnabled.value) {
requestOneShotLocation()
}
}
@ -272,9 +263,21 @@ class LocationChannelManager private constructor(private val context: Context) {
* Disable location services (user-controlled toggle)
*/
fun disableLocationServices() {
LiveLocationPrivacyGate.update(false)
Log.d(TAG, "disableLocationServices() called by user")
_locationServicesEnabled.value = false
saveLocationServicesState(false)
clearLiveLocationState(invalidateAccess = false)
// Stop any ongoing location operations
endLiveRefresh()
// Clear available channels when location is disabled
_availableChannels.value = emptyList()
_locationNames.value = emptyMap()
// If user had a location channel selected, switch back to mesh
if (_selectedChannel.value is ChannelID.Location) {
select(ChannelID.Mesh)
}
}
/**
@ -284,166 +287,80 @@ class LocationChannelManager private constructor(private val context: Context) {
* Check if both the app toggle and system location are enabled
*/
fun isLocationServicesEnabled(): Boolean {
return LiveLocationPrivacyGate.isEnabled && _systemLocationEnabled.value
}
fun canUseSelectedLocationChannel(channel: GeohashChannel): Boolean {
if (_selectedChannel.value != ChannelID.Location(channel)) return false
return selectedLocationSource == LocationSelectionSource.MANUAL ||
LiveLocationPrivacyGate.captureToken() != null
}
fun isSelectedChannelLiveDerived(channel: GeohashChannel): Boolean =
_selectedChannel.value == ChannelID.Location(channel) &&
selectedLocationSource == LocationSelectionSource.NEARBY
fun liveLocationTokenForSelectedChannel(channel: GeohashChannel): Long? {
if (!isSelectedChannelLiveDerived(channel)) return null
return LiveLocationPrivacyGate.captureToken()
}
private fun selectInternal(
channel: ChannelID,
source: LocationSelectionSource?,
teleported: Boolean
) {
selectedLocationSource = source
_teleported.value = when (channel) {
ChannelID.Mesh -> false
is ChannelID.Location -> teleported
}
_selectedChannel.value = channel
saveChannelSelection(channel, source)
}
/**
* Revokes all GPS-derived in-memory state and pending work. This deliberately does not
* change the persisted soft setting when Android's hard permission or system provider is
* temporarily unavailable.
*/
private fun clearLiveLocationState(invalidateAccess: Boolean = true) {
if (invalidateAccess) LiveLocationPrivacyGate.invalidate()
cancelLiveLocationWork()
_isLoadingLocation.value = false
NostrIdentityBridge.clearGeohashIdentityCache(
_availableChannels.value.map { it.geohash }
)
_availableChannels.value = emptyList()
_locationNames.value = emptyMap()
when {
_selectedChannel.value is ChannelID.Location &&
selectedLocationSource == LocationSelectionSource.NEARBY -> {
selectInternal(ChannelID.Mesh, source = null, teleported = false)
}
_selectedChannel.value is ChannelID.Location -> {
// Manual channels remain usable for teleports, bookmarks, and DMs. Never use
// a previously retained GPS fix to classify them while live access is off.
selectedLocationSource = LocationSelectionSource.MANUAL
_teleported.value = true
saveChannelSelection(_selectedChannel.value, selectedLocationSource)
}
}
}
private fun cancelLiveLocationWork() {
locationProvider.cancel()
activeLocationUpdateCallback = null
geocodingJob?.cancel()
geocodingJob = null
return _locationServicesEnabled.value && _systemLocationEnabled.value
}
// MARK: - Location Operations
private fun requestOneShotLocation() {
if (!isLocationServicesEnabled() ||
syncPermissionState() != PermissionState.AUTHORIZED
) {
if (!checkAndSyncPermission()) {
Log.w(TAG, "No location permission for one-shot request")
return
}
LiveLocationPrivacyGate.resumeAccess()
val token = LiveLocationPrivacyGate.captureToken() ?: return
Log.d(TAG, "Requesting one-shot location")
// Set loading state initially
_isLoadingLocation.value = true
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.getLastKnownLocation { cached ->
if (!canUseLiveLocation(token)) return@getLastKnownLocation
if (cached != null) {
onLocationUpdated(cached, token)
} else {
LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.requestFreshLocation { fresh ->
if (!canUseLiveLocation(token)) return@requestFreshLocation
if (fresh != null) {
onLocationUpdated(fresh, token)
} else {
Log.w(TAG, "Failed to get fresh location")
_isLoadingLocation.value = false
}
}
locationProvider.getLastKnownLocation { cached ->
// If we have a cached location and it's reasonably recent (e.g. < 5 mins), use it
// For now, we just use it if it exists, similar to previous logic
if (cached != null) {
Log.d(TAG, "Using last known location: ${cached.latitude}, ${cached.longitude}")
onLocationUpdated(cached)
} else {
Log.d(TAG, "No last known location available, requesting fresh...")
locationProvider.requestFreshLocation { fresh ->
if (fresh != null) {
Log.d(TAG, "Fresh location received: ${fresh.latitude}, ${fresh.longitude}")
onLocationUpdated(fresh)
} else {
Log.w(TAG, "Failed to get fresh location")
_isLoadingLocation.value = false
}
}
}
}
if (!started) _isLoadingLocation.value = false
}
private fun onLocationUpdated(location: Location, token: Long) {
LiveLocationPrivacyGate.runIfAllowed(token) {
if (!_systemLocationEnabled.value || !hasRuntimeLocationPermission()) return@runIfAllowed
_isLoadingLocation.value = false
computeChannels(location, token)
reverseGeocodeIfNeeded(location, token)
}
private fun onLocationUpdated(location: Location) {
lastLocation = location
_isLoadingLocation.value = false
computeChannels(location)
reverseGeocodeIfNeeded(location)
}
// MARK: - Helpers
private fun hasRuntimeLocationPermission(): Boolean {
return ActivityCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
fun syncPermissionState(): PermissionState {
val newState = if (hasRuntimeLocationPermission()) {
private fun getCurrentPermissionStatus(): PermissionState {
return if (checkAndSyncPermission()) {
PermissionState.AUTHORIZED
} else {
PermissionState.DENIED
}
}
private fun checkAndSyncPermission(): Boolean {
val hasPermission = ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
val newState = if (hasPermission) PermissionState.AUTHORIZED else PermissionState.DENIED
if (_permissionState.value != newState) {
Log.d(TAG, "Permission state updated to: $newState")
_permissionState.value = newState
}
if (newState == PermissionState.DENIED) {
clearLiveLocationState()
}
return newState
return hasPermission
}
private fun canUseLiveLocation(token: Long): Boolean {
return LiveLocationPrivacyGate.accepts(token) &&
_systemLocationEnabled.value &&
hasRuntimeLocationPermission()
}
private fun computeChannels(location: Location, token: Long) {
if (!canUseLiveLocation(token)) return
private fun computeChannels(location: Location) {
Log.d(TAG, "Computing channels for location: ${location.latitude}, ${location.longitude}")
val levels = GeohashChannelLevel.allCases()
val result = mutableListOf<GeohashChannel>()
for (level in levels) {
val geohash = Geohash.encode(
latitude = location.latitude,
@ -451,56 +368,54 @@ class LocationChannelManager private constructor(private val context: Context) {
precision = level.precision
)
result.add(GeohashChannel(level = level, geohash = geohash))
Log.v(TAG, "Generated ${level.displayName}: $geohash")
}
if (!canUseLiveLocation(token)) return
_availableChannels.value = result
// Recompute teleported status based on current location vs selected channel
val selectedChannelValue = _selectedChannel.value
if (selectedChannelValue is ChannelID.Location &&
selectedLocationSource == LocationSelectionSource.NEARBY
) {
val currentGeohash = Geohash.encode(
latitude = location.latitude,
longitude = location.longitude,
precision = selectedChannelValue.channel.level.precision
)
_teleported.value = currentGeohash != selectedChannelValue.channel.geohash
} else if (selectedChannelValue is ChannelID.Mesh) {
_teleported.value = false
when (selectedChannelValue) {
is ChannelID.Mesh -> {
_teleported.value = false
}
is ChannelID.Location -> {
val currentGeohash = Geohash.encode(
latitude = location.latitude,
longitude = location.longitude,
precision = selectedChannelValue.channel.level.precision
)
val isTeleported = currentGeohash != selectedChannelValue.channel.geohash
_teleported.value = isTeleported
Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})")
}
}
}
private fun reverseGeocodeIfNeeded(location: Location, token: Long) {
if (!canUseLiveLocation(token)) return
private fun reverseGeocodeIfNeeded(location: Location) {
// Cancel any pending geocoding job to avoid race conditions
geocodingJob?.cancel()
geocodingJob = scope.launch(Dispatchers.IO) {
try {
if (!canUseLiveLocation(token)) return@launch
val addresses = geocoderProvider.getFromLocation(
location.latitude,
location.longitude,
1,
liveLocationToken = token
)
Log.d(TAG, "Starting reverse geocoding")
val addresses = geocoderProvider.getFromLocation(location.latitude, location.longitude, 1)
if (!isActive || !canUseLiveLocation(token)) return@launch
if (!isActive) return@launch
if (addresses.isNotEmpty()) {
val address = addresses[0]
val names = namesByLevel(address)
LiveLocationPrivacyGate.runIfAllowed(token) {
if (_systemLocationEnabled.value && hasRuntimeLocationPermission()) {
_locationNames.value = names
}
}
Log.d(TAG, "Reverse geocoding result: $names")
_locationNames.value = names
} else {
Log.w(TAG, "No reverse geocoding results")
}
} catch (e: Exception) {
if (e !is CancellationException) {
Log.e(TAG, "Reverse geocoding failed")
Log.e(TAG, "Reverse geocoding failed: ${e.message}")
}
}
}
@ -554,10 +469,7 @@ class LocationChannelManager private constructor(private val context: Context) {
/**
* Save current channel selection to persistent storage
*/
private fun saveChannelSelection(
channel: ChannelID,
source: LocationSelectionSource?
) {
private fun saveChannelSelection(channel: ChannelID) {
try {
val channelData = when (channel) {
is ChannelID.Mesh -> gson.toJson(PersistedChannel(mesh = true))
@ -565,14 +477,14 @@ class LocationChannelManager private constructor(private val context: Context) {
PersistedChannel(
mesh = false,
level = channel.channel.level.name,
geohash = channel.channel.geohash,
source = source?.name
geohash = channel.channel.geohash
)
)
}
dataManager?.saveLastGeohashChannel(channelData)
Log.d(TAG, "Saved channel selection: ${channel.displayName}")
} catch (e: Exception) {
Log.e(TAG, "Failed to save channel selection")
Log.e(TAG, "Failed to save channel selection: ${e.message}")
}
}
@ -585,44 +497,30 @@ class LocationChannelManager private constructor(private val context: Context) {
if (!channelData.isNullOrBlank()) {
val persisted = gson.fromJson(channelData, PersistedChannel::class.java)
val channel = persisted?.toChannel()
val source = persisted?.selectionSource()
val canRestore = channel !is ChannelID.Location ||
source == LocationSelectionSource.MANUAL ||
(LiveLocationPrivacyGate.isEnabled &&
_systemLocationEnabled.value &&
_permissionState.value == PermissionState.AUTHORIZED)
if (channel != null && canRestore) {
if (channel != null) {
_selectedChannel.value = channel
selectedLocationSource = if (channel is ChannelID.Location) source else null
_teleported.value = channel is ChannelID.Location &&
source == LocationSelectionSource.MANUAL
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
} else {
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
_selectedChannel.value = ChannelID.Mesh
selectedLocationSource = null
_teleported.value = false
saveChannelSelection(ChannelID.Mesh, source = null)
}
} else {
Log.d(TAG, "No persisted channel found, defaulting to Mesh")
_selectedChannel.value = ChannelID.Mesh
selectedLocationSource = null
}
} catch (e: JsonSyntaxException) {
Log.e(TAG, "Failed to parse persisted channel data")
Log.e(TAG, "Failed to parse persisted channel data: ${e.message}")
_selectedChannel.value = ChannelID.Mesh
selectedLocationSource = null
} catch (e: Exception) {
Log.e(TAG, "Failed to load persisted channel")
Log.e(TAG, "Failed to load persisted channel: ${e.message}")
_selectedChannel.value = ChannelID.Mesh
selectedLocationSource = null
}
}
data class PersistedChannel(
val mesh: Boolean,
val level: String? = null,
val geohash: String? = null,
val source: String? = null
val geohash: String? = null
) {
fun toChannel(): ChannelID? {
return if (mesh) {
@ -633,13 +531,6 @@ class LocationChannelManager private constructor(private val context: Context) {
ChannelID.Location.fromPersisted(levelName, gh)
}
}
fun selectionSource(): LocationSelectionSource? {
if (mesh) return null
return source?.let {
runCatching { LocationSelectionSource.valueOf(it) }.getOrNull()
} ?: LocationSelectionSource.NEARBY
}
}
/**
@ -648,8 +539,8 @@ class LocationChannelManager private constructor(private val context: Context) {
fun clearPersistedChannel() {
dataManager?.clearLastGeohashChannel()
_selectedChannel.value = ChannelID.Mesh
selectedLocationSource = null
_teleported.value = false
Log.d(TAG, "Cleared persisted channel selection")
}
// MARK: - Location Services State Persistence
@ -660,8 +551,9 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun saveLocationServicesState(enabled: Boolean) {
try {
dataManager?.saveLocationServicesEnabled(enabled)
Log.d(TAG, "Saved location services state: $enabled")
} catch (e: Exception) {
Log.e(TAG, "Failed to save location services state")
Log.e(TAG, "Failed to save location services state: ${e.message}")
}
}
@ -671,10 +563,11 @@ class LocationChannelManager private constructor(private val context: Context) {
private fun loadLocationServicesState() {
try {
val enabled = dataManager?.isLocationServicesEnabled() ?: false
LiveLocationPrivacyGate.update(enabled)
_locationServicesEnabled.value = enabled
Log.d(TAG, "Loaded location services state: $enabled")
} catch (e: Exception) {
Log.e(TAG, "Failed to load location services state")
LiveLocationPrivacyGate.update(false)
Log.e(TAG, "Failed to load location services state: ${e.message}")
_locationServicesEnabled.value = false
}
}
@ -682,6 +575,7 @@ class LocationChannelManager private constructor(private val context: Context) {
* Cleanup resources
*/
fun cleanup() {
Log.d(TAG, "Cleaning up LocationChannelManager")
endLiveRefresh()
locationProvider.cancel()

View File

@ -6,7 +6,7 @@ import android.location.Location
* Abstraction for location providers to support both
* System (LocationManager) and Google Play Services (FusedLocationProvider).
*/
internal interface LocationProvider {
interface LocationProvider {
/**
* Get the last known location from cache.
* @param callback Called with the location or null if not found/error.

View File

@ -4,79 +4,53 @@ import android.location.Address
import android.util.Log
import com.bitchat.android.net.OkHttpProvider
import com.google.gson.Gson
import java.io.IOException
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Request
import okhttp3.Response
import java.util.Locale
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class OpenStreetMapGeocoderProvider : GeocoderProvider {
private val TAG = "OSMGeocoderProvider"
private val gson = Gson()
private val userAgent = "Bitchat-Android/1.0"
override suspend fun getFromLocation(
latitude: Double,
longitude: Double,
maxResults: Int,
liveLocationToken: Long?
): List<Address> {
return suspendCancellableCoroutine { continuation ->
override suspend fun getFromLocation(latitude: Double, longitude: Double, maxResults: Int): List<Address> {
return withContext(Dispatchers.IO) {
val lang = Locale.getDefault().toLanguageTag()
// Using format=jsonv2 for structured address breakdown
val url = "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=$latitude&lon=$longitude&zoom=18&addressdetails=1&accept-language=$lang"
val request = Request.Builder()
.url(url)
.header("User-Agent", userAgent)
.build()
val call = OkHttpProvider.httpClient().newCall(request)
continuation.invokeOnCancellation { call.cancel() }
val enqueueRequest = {
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isActive) {
Log.w(TAG, "OSM geocoding request failed")
continuation.resume(emptyList())
}
try {
val request = Request.Builder()
.url(url)
.header("User-Agent", userAgent)
.build()
val response = OkHttpProvider.httpClient().newCall(request).execute()
if (!response.isSuccessful) {
Log.e(TAG, "OSM Request failed: ${response.code}")
response.close()
return@withContext emptyList<Address>()
}
override fun onResponse(call: Call, response: Response) {
val addresses = response.use {
if (!it.isSuccessful) {
Log.w(TAG, "OSM geocoding request returned ${it.code}")
return@use emptyList()
}
val body = response.body?.string()
response.close()
val body = it.body?.string()
if (body.isNullOrEmpty()) return@use emptyList()
if (body.isNullOrEmpty()) return@withContext emptyList<Address>()
runCatching {
val osmResponse = gson.fromJson(body, OsmResponse::class.java)
if (osmResponse?.address == null) emptyList()
else listOf(mapToAddress(osmResponse, latitude, longitude))
}.getOrElse {
Log.w(TAG, "OSM geocoding response could not be parsed")
emptyList()
}
}
if (continuation.isActive) continuation.resume(addresses)
try {
val osmResponse = gson.fromJson(body, OsmResponse::class.java)
if (osmResponse?.address == null) return@withContext emptyList<Address>()
val address = mapToAddress(osmResponse, latitude, longitude)
listOf(address)
} catch (e: Exception) {
Log.e(TAG, "OSM Parse failed: ${e.message}")
emptyList<Address>()
}
})
}
val started = if (liveLocationToken == null) {
enqueueRequest()
true
} else {
LiveLocationPrivacyGate.runIfAllowed(
liveLocationToken,
enqueueRequest
)
}
if (!started && continuation.isActive) {
continuation.resume(emptyList())
} catch (e: Exception) {
Log.e(TAG, "OSM Geocoding failed", e)
emptyList<Address>()
}
}
}

View File

@ -9,11 +9,10 @@ import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.util.Log
import androidx.core.app.ActivityCompat
internal class SystemLocationProvider(private val context: Context) : LocationProvider {
class SystemLocationProvider(private val context: Context) : LocationProvider {
companion object {
private const val TAG = "SystemLocationProvider"
@ -26,13 +25,10 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr
private val activeListeners = mutableMapOf<(Location) -> Unit, LocationListener>()
private val activeOneShotListeners = mutableMapOf<(Location?) -> Unit, LocationListener>()
private val activeOneShotRunnables = mutableMapOf<(Location?) -> Unit, Runnable>()
private val activeOneShotCancellationSignals = mutableMapOf<(Location?) -> Unit, CancellationSignal>()
private fun hasLocationPermission(): Boolean {
return LiveLocationPrivacyGate.isEnabled &&
(ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
)
}
@SuppressLint("MissingPermission")
@ -53,9 +49,9 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr
}
}
}
callback(bestLocation.takeIf { LiveLocationPrivacyGate.isEnabled })
callback(bestLocation)
} catch (e: Exception) {
Log.e(TAG, "Error getting last-known location")
Log.e(TAG, "Error getting last known location: ${e.message}")
callback(null)
}
}
@ -80,27 +76,12 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr
Log.d(TAG, "Requesting fresh location from $provider")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val cancellationSignal = CancellationSignal()
synchronized(activeOneShotCancellationSignals) {
activeOneShotCancellationSignals[callback] = cancellationSignal
}
try {
locationManager.getCurrentLocation(
provider,
cancellationSignal,
context.mainExecutor
) { location ->
synchronized(activeOneShotCancellationSignals) {
activeOneShotCancellationSignals.remove(callback)
}
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
}
} catch (e: Exception) {
synchronized(activeOneShotCancellationSignals) {
activeOneShotCancellationSignals.remove(callback)
}
cancellationSignal.cancel()
throw e
locationManager.getCurrentLocation(
provider,
null,
context.mainExecutor
) { location ->
callback(location)
}
} else {
// For older versions, use requestSingleUpdate with timeout mechanism
@ -113,7 +94,7 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr
try {
locationManager.removeUpdates(listener)
} catch (e: Exception) {
Log.e(TAG, "Error removing timed-out listener")
Log.e(TAG, "Error removing timed out listener: ${e.message}")
}
}
}
@ -132,9 +113,9 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr
try {
locationManager.removeUpdates(this)
} catch (e: Exception) {
Log.e(TAG, "Error removing updates in callback")
Log.e(TAG, "Error removing updates in callback: ${e.message}")
}
callback(location.takeIf { LiveLocationPrivacyGate.isEnabled })
callback(location)
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
@ -159,7 +140,7 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr
callback(null)
}
} catch (e: Exception) {
Log.e(TAG, "Error requesting fresh location")
Log.e(TAG, "Error requesting fresh location: ${e.message}")
callback(null)
}
}
@ -175,7 +156,7 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr
try {
val listener = object : LocationListener {
override fun onLocationChanged(location: Location) {
if (LiveLocationPrivacyGate.isEnabled) callback(location)
callback(location)
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
@ -208,7 +189,7 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr
}
} catch (e: Exception) {
Log.e(TAG, "Error requesting location updates")
Log.e(TAG, "Error requesting location updates: ${e.message}")
}
}
@ -223,7 +204,7 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr
Log.d(TAG, "Removed location updates")
}
} catch (e: Exception) {
Log.e(TAG, "Error removing updates")
Log.e(TAG, "Error removing updates: ${e.message}")
}
}
@ -249,13 +230,9 @@ internal class SystemLocationProvider(private val context: Context) : LocationPr
}
activeOneShotRunnables.clear()
}
synchronized(activeOneShotCancellationSignals) {
activeOneShotCancellationSignals.values.forEach { it.cancel() }
activeOneShotCancellationSignals.clear()
}
Log.d(TAG, "Cancelled all system location requests")
} catch (e: Exception) {
Log.e(TAG, "Error cancelling system provider")
Log.e(TAG, "Error cancelling system provider: ${e.message}")
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,119 +0,0 @@
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,255 +0,0 @@
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
import kotlinx.coroutines.launch
import java.io.File
/**
* ViewModel for managing hotspot state and lifecycle.
*/
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)
val state: StateFlow<HotspotState> = _state.asStateFlow()
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
}
Log.d(TAG, "Starting hotspot with APK: ${apkFile.name}")
_state.value = HotspotState.Starting
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
manager.startHotspot(object : HotspotManager.HotspotCallback {
override fun onHotspotStarted() {
viewModelScope.launch {
Log.d(TAG, "Hotspot started successfully")
// Get connection info
val info = manager.getConnectionInfo()
if (info == null) {
failWith("Failed to get hotspot connection info")
return@launch
}
// Start web server
try {
val server = ApkWebServer(context, apkFile)
server.startServer()
webServer = server
Log.d(TAG, "Web server started on port ${ApkWebServer.DEFAULT_PORT}")
// Update state with connection info
_state.value = HotspotState.Active(
ssid = info.ssid,
password = info.password,
ipAddress = info.ipAddress,
port = ApkWebServer.DEFAULT_PORT,
connectedPeers = info.connectedPeers
)
} catch (e: Exception) {
Log.e(TAG, "Failed to start web server", e)
failWith("Failed to start web server: ${e.message}")
}
}
}
override fun onConnectionInfoUpdated(info: HotspotManager.ConnectionInfo?) {
viewModelScope.launch {
// Update peer count if we're active
val currentState = _state.value
if (currentState is HotspotState.Active && info != null) {
_state.value = currentState.copy(connectedPeers = info.connectedPeers)
}
}
}
override fun onExistingGroupConflict(groupName: String) {
viewModelScope.launch {
// 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)
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
val manager = hotspotManager
hotspotManager = null
// 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
)
}
/**
* Reset to intro state (for retry after error).
*/
fun resetToIntro() {
stopHotspot()
_state.value = HotspotState.Intro
}
override fun onCleared() {
super.onCleared()
Log.d(TAG, "ViewModel cleared, stopping hotspot")
stopHotspot()
}
/**
* Hotspot state sealed class.
*/
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,
val ipAddress: String,
val port: Int,
val connectedPeers: Int
) : HotspotState()
data class Error(val message: String) : HotspotState()
}
}

View File

@ -1,126 +0,0 @@
package com.bitchat.android.hotspot
import android.graphics.Bitmap
import android.util.Log
import androidx.core.graphics.createBitmap
import androidx.core.graphics.set
import com.google.zxing.BarcodeFormat
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.QRCodeWriter
/**
* Utility for generating QR codes for Wi-Fi connection and URL.
*/
object QrCodeGenerator {
private const val TAG = "QrCodeGenerator"
/**
* Generate QR code for Wi-Fi connection.
* Format: WIFI:S:{SSID};T:WPA;P:{PASSWORD};;
*
* This format is recognized by most Android/iOS devices for instant Wi-Fi connection.
*
* @param ssid Wi-Fi network name
* @param password Wi-Fi password
* @param sizePx Size of the QR code in pixels
* @return Bitmap of the QR code, or null on error
*/
fun generateWifiQr(ssid: String, password: String, sizePx: Int): Bitmap? {
if (ssid.isBlank() || password.isBlank()) {
Log.w(TAG, "SSID or password is blank")
return null
}
// Escape special characters
val escapedSsid = escapeWifiString(ssid)
val escapedPassword = escapeWifiString(password)
// Format: WIFI:S:{SSID};T:WPA;P:{PASSWORD};;
val wifiString = "WIFI:S:$escapedSsid;T:WPA;P:$escapedPassword;;"
Log.d(TAG, "Generating Wi-Fi QR code for SSID: $ssid")
return generateQrBitmap(wifiString, sizePx)
}
/**
* Generate QR code for URL.
*
* @param url Website URL (e.g., "http://192.168.49.1:9999")
* @param sizePx Size of the QR code in pixels
* @return Bitmap of the QR code, or null on error
*/
fun generateUrlQr(url: String, sizePx: Int): Bitmap? {
if (url.isBlank()) {
Log.w(TAG, "URL is blank")
return null
}
Log.d(TAG, "Generating URL QR code: $url")
return generateQrBitmap(url, sizePx)
}
/**
* Generate QR code bitmap from string data.
*
* @param data String data to encode
* @param sizePx Size of the QR code in pixels
* @return Bitmap of the QR code, or null on error
*/
private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? {
if (data.isBlank() || sizePx <= 0) {
Log.w(TAG, "Invalid data or size: data.length=${data.length}, sizePx=$sizePx")
return null
}
return try {
val matrix = QRCodeWriter().encode(
data,
BarcodeFormat.QR_CODE,
sizePx,
sizePx
)
bitmapFromMatrix(matrix)
} catch (e: Exception) {
Log.e(TAG, "Error generating QR code", e)
null
}
}
/**
* Convert BitMatrix to Bitmap.
* Pattern from VerificationSheet.kt.
*/
private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap {
val width = matrix.width
val height = matrix.height
val bitmap = createBitmap(width, height)
for (x in 0 until width) {
for (y in 0 until height) {
bitmap[x, y] = if (matrix[x, y]) {
android.graphics.Color.BLACK
} else {
android.graphics.Color.WHITE
}
}
}
return bitmap
}
/**
* Escape special characters in Wi-Fi SSID/password for QR code format.
* Special characters that need escaping: \ ; , " :
*/
private fun escapeWifiString(input: String): String {
return input
.replace("\\", "\\\\") // Backslash must be escaped first
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\"", "\\\"")
.replace(":", "\\:")
}
}

View File

@ -1,6 +1,5 @@
package com.bitchat.android.identity
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
@ -8,8 +7,6 @@ import androidx.security.crypto.MasterKey
import java.security.MessageDigest
import android.util.Base64
import android.util.Log
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.util.hexEncodedString
import androidx.core.content.edit
@ -21,7 +18,7 @@ import androidx.core.content.edit
* - Secure storage using Android EncryptedSharedPreferences
* - Fingerprint calculation and identity validation
*/
class SecureIdentityStateManager {
class SecureIdentityStateManager(private val context: Context) {
companion object {
private const val TAG = "SecureIdentityStateManager"
@ -35,25 +32,12 @@ class SecureIdentityStateManager {
private const val KEY_CACHED_PEER_NOISE_KEYS = "cached_peer_noise_keys"
private const val KEY_CACHED_NOISE_FINGERPRINTS = "cached_noise_fingerprints"
private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames"
private const val KEY_PRIVATE_MEDIA_CAPABILITY_PINS = "private_media_capability_pins_v1"
private const val KEY_AUTHENTICATED_PEER_STATES = "authenticated_peer_states_v1"
// BLE, Wi-Fi Aware, and Noise services each hold their own manager
// instance over the same encrypted preferences. Serialize pin updates
// process-wide so concurrent promotions cannot lose one another or
// race a panic wipe.
private val privateMediaPinsLock = Any()
private var privateMediaPinsEpoch = 0L
}
private val prefs: SharedPreferences
private val lock = Any()
private var privateMediaPinsEpochAtCreation: Long
constructor(context: Context) {
privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch
}
init {
// Create master key for encryption
val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
@ -68,15 +52,6 @@ class SecureIdentityStateManager {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
/** Test-only storage injection; production always uses encrypted prefs. */
internal constructor(prefs: SharedPreferences, testOnly: Boolean) {
require(testOnly) { "Plain SharedPreferences are test-only" }
privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch
}
this.prefs = prefs
}
// MARK: - Static Key Management
@ -318,78 +293,6 @@ class SecureIdentityStateManager {
prefs.edit { putStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, current) }
}
}
// MARK: - Authenticated private-media capability pins
fun isPrivateMediaCapable(fingerprint: String): Boolean {
if (!isValidFingerprint(fingerprint)) return false
return synchronized(privateMediaPinsLock) {
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) {
return@synchronized false
}
prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet())
?.any { it.equals(fingerprint, ignoreCase = true) } == true
}
}
/** Persist capabilities and Ed25519 key from a decoded Noise 0x21 proof in one edit. */
@SuppressLint("UseKtx")
fun storeAuthenticatedPeerState(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit = {}
): Boolean {
if (!isValidFingerprint(fingerprint) || state.signingPublicKey.size != 32) return false
val normalizedFingerprint = fingerprint.lowercase()
return synchronized(privateMediaPinsLock) {
// A controller that survived panic must not republish pre-wipe proof state.
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized false
val records = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet())
?.toMutableSet() ?: mutableSetOf()
records.removeAll { it.startsWith("$normalizedFingerprint:") }
val capabilitiesHex = java.lang.Long.toUnsignedString(state.capabilities.rawValue, 16)
records.add(
"$normalizedFingerprint:$capabilitiesHex:${state.signingPublicKey.hexEncodedString()}"
)
val editor = prefs.edit().putStringSet(KEY_AUTHENTICATED_PEER_STATES, records)
if (state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) {
val pins = prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet())
?.mapTo(mutableSetOf()) { it.lowercase() } ?: mutableSetOf()
pins.add(normalizedFingerprint)
editor.putStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, pins)
}
// This result is a security boundary: do not publish the Ed key in memory unless the
// encrypted identity record and its HSTS pin were durably committed together.
editor.commit().also { committed ->
if (committed) onCommitted()
}
}
}
fun getAuthenticatedPeerState(fingerprint: String): AuthenticatedPeerState? {
if (!isValidFingerprint(fingerprint)) return null
return synchronized(privateMediaPinsLock) {
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized null
val prefix = "${fingerprint.lowercase()}:"
val record = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet())
?.firstOrNull { it.startsWith(prefix) } ?: return@synchronized null
val fields = record.split(':', limit = 3)
if (fields.size != 3) return@synchronized null
val capabilities = runCatching {
PeerCapabilities(java.lang.Long.parseUnsignedLong(fields[1], 16))
}.getOrNull() ?: return@synchronized null
val signingKeyHex = fields[2]
if (!signingKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return@synchronized null
val signingKey = runCatching {
signingKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}.getOrNull() ?: return@synchronized null
AuthenticatedPeerState(capabilities, signingKey)
}
}
fun getAuthenticatedSigningKey(fingerprint: String): ByteArray? =
getAuthenticatedPeerState(fingerprint)?.signingPublicKey?.copyOf()
// MARK: - Peer ID Rotation Management (removed)
// Android now derives peer ID from the persisted Noise identity fingerprint.
@ -465,16 +368,9 @@ class SecureIdentityStateManager {
/**
* Clear all identity data (for panic mode)
*/
@SuppressLint("UseKtx")
fun clearIdentityData() {
try {
synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch += 1
privateMediaPinsEpochAtCreation = privateMediaPinsEpoch
if (!prefs.edit().clear().commit()) {
Log.e(TAG, "Identity preference wipe could not be committed")
}
}
prefs.edit().clear().apply()
Log.w(TAG, "All identity data cleared")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear identity data: ${e.message}")
@ -528,11 +424,4 @@ 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

@ -1,37 +0,0 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.noise.NoisePeerIdentity
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.toHexString
/** Canonical, side-effect-free preflight for a self-signed mesh announcement. */
object AnnouncementIdentityValidator {
private const val MAX_CLOCK_SKEW_MS = 10 * 60 * 1_000L
fun verify(
packet: BitchatPacket,
claimedPeerID: String,
nowMs: Long = System.currentTimeMillis(),
verifyEd25519: (signature: ByteArray, data: ByteArray, publicKey: ByteArray) -> Boolean
): IdentityAnnouncement? {
if (packet.type != MessageType.ANNOUNCE.value) return null
val now = nowMs.coerceAtLeast(0).toULong()
val skew = if (packet.timestamp >= now) packet.timestamp - now else now - packet.timestamp
if (skew > MAX_CLOCK_SKEW_MS.toULong()) return null
val announcement = IdentityAnnouncement.decode(packet.payload) ?: return null
if (announcement.signingPublicKey.size != 32) return null
val derivedPeerID = NoisePeerIdentity.derivePeerID(announcement.noisePublicKey) ?: return null
if (packet.senderID.toHexString() != derivedPeerID || claimedPeerID != derivedPeerID) return null
val signature = packet.signature ?: return null
val canonicalData = packet.toBinaryDataForSigning() ?: return null
return if (verifyEd25519(signature, canonicalData, announcement.signingPublicKey)) {
announcement
} else {
null
}
}
}

View File

@ -1,235 +0,0 @@
package com.bitchat.android.mesh
import android.content.Context
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoisePeerIdentity
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
internal interface AuthenticatedPeerStateStore {
fun load(fingerprint: String): AuthenticatedPeerState?
fun persist(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit
): Boolean
fun isPrivateMediaPinned(fingerprint: String): Boolean
}
internal class SecureAuthenticatedPeerStateStore(context: Context) : AuthenticatedPeerStateStore {
private val identityState = SecureIdentityStateManager(context.applicationContext)
override fun load(fingerprint: String): AuthenticatedPeerState? =
identityState.getAuthenticatedPeerState(fingerprint)
override fun persist(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit
): Boolean = identityState.storeAuthenticatedPeerState(fingerprint, state, onCommitted)
override fun isPrivateMediaPinned(fingerprint: String): Boolean =
identityState.isPrivateMediaCapable(fingerprint)
}
internal sealed interface AuthenticatedPeerStateStatus {
data object Missing : AuthenticatedPeerStateStatus
data object Awaiting : AuthenticatedPeerStateStatus
data object TimedOut : AuthenticatedPeerStateStatus
data class Proven(val state: AuthenticatedPeerState) : AuthenticatedPeerStateStatus
}
/** Fresh, generation-scoped authenticated peer-state exchange for Noise payload 0x21. */
internal class AuthenticatedPeerStateCoordinator(
private val scope: CoroutineScope,
private val authenticatedSessionProvider: (String) -> AuthenticatedNoiseSession?,
private val withAuthenticatedSession: (
String,
AuthenticatedNoiseSession,
() -> Boolean
) -> Boolean,
private val store: AuthenticatedPeerStateStore,
private val localStateProvider: () -> AuthenticatedPeerState,
private val applyAuthenticatedState: (String, ByteArray, AuthenticatedPeerState) -> Unit,
private val sendState: (String, AuthenticatedPeerState, AuthenticatedNoiseSession) -> Boolean,
private val onResolution: (String) -> Unit,
private val proofTimeoutMs: Long = 5_000L
) {
private data class SessionState(
val authenticatedSession: AuthenticatedNoiseSession,
val fingerprint: String,
var status: AuthenticatedPeerStateStatus,
var echoSent: Boolean,
var timeoutJob: Job? = null
)
private val lock = Any()
private val sessions = ConcurrentHashMap<String, SessionState>()
fun onSessionAuthenticated(
peerID: String,
authenticatedRemoteStatic: ByteArray,
authenticatedSessionToken: ByteArray
) {
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteStatic)) return
if (authenticatedSessionToken.size != 32 ||
authenticatedSessionToken.all { it == 0.toByte() }
) return
val authenticatedSession = AuthenticatedNoiseSession(
authenticatedRemoteStatic.copyOf(),
authenticatedSessionToken.copyOf()
)
ensureSession(peerID, authenticatedSession)
}
/** Install one watchdog/exchange for this exact live generation, without resetting it. */
private fun ensureSession(
peerID: String,
authenticatedSession: AuthenticatedNoiseSession
): SessionState? {
val authenticatedRemoteStatic = authenticatedSession.remoteStaticKey
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteStatic)) return null
if (authenticatedSession.sessionToken.size != 32 ||
authenticatedSession.sessionToken.all { it == 0.toByte() }
) return null
// Ignore a delayed callback or policy snapshot if a later generation is already active.
if (authenticatedSessionProvider(peerID) != authenticatedSession) return null
val session = SessionState(
authenticatedSession = authenticatedSession,
fingerprint = fingerprint(authenticatedRemoteStatic),
status = AuthenticatedPeerStateStatus.Awaiting,
echoSent = false
)
val installed = withAuthenticatedSession(peerID, authenticatedSession) {
synchronized(lock) {
val existing = sessions[peerID]
if (existing?.authenticatedSession == authenticatedSession) {
return@synchronized false
}
sessions.put(peerID, session)?.timeoutJob?.cancel()
true
}
}
if (!installed) return synchronized(lock) { sessions[peerID] }
// Emit for every authenticated generation/rekey. Failure does not relax the watchdog.
runCatching { sendState(peerID, localStateProvider(), authenticatedSession) }
val timeout = scope.launch {
delay(proofTimeoutMs)
val resolved = synchronized(lock) {
val current = sessions[peerID]
if (current !== session || current.status !is AuthenticatedPeerStateStatus.Awaiting) {
false
} else {
current.status = AuthenticatedPeerStateStatus.TimedOut
true
}
}
if (resolved) onResolution(peerID)
}
synchronized(lock) {
if (sessions[peerID] === session && session.status is AuthenticatedPeerStateStatus.Awaiting) {
session.timeoutJob = timeout
} else {
timeout.cancel()
}
}
return session
}
/** Accept the first valid proof for this generation; repeated equal proofs are idempotent. */
fun receive(
peerID: String,
state: AuthenticatedPeerState,
decryptedSession: AuthenticatedNoiseSession
): Boolean {
val remoteStatic = decryptedSession.remoteStaticKey
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, remoteStatic)) return false
if (decryptedSession.sessionToken.size != 32 ||
decryptedSession.sessionToken.all { it == 0.toByte() }
) return false
val currentFingerprint = fingerprint(remoteStatic)
var shouldEcho = false
var echoSession: AuthenticatedNoiseSession? = null
val accepted = withAuthenticatedSession(peerID, decryptedSession) {
synchronized(lock) {
val current = sessions[peerID] ?: return@synchronized false
if (current.fingerprint != currentFingerprint ||
current.authenticatedSession != decryptedSession
) return@synchronized false
val proven = current.status as? AuthenticatedPeerStateStatus.Proven
if (proven != null) return@synchronized proven.state == state
try {
// Persist before publishing the replacement Ed key in memory, so a restart
// cannot reopen copied-static first-announce poisoning. The Noise manager
// lease prevents this generation from being replaced during the transition.
if (!store.persist(currentFingerprint, state) {
// Publish while the persistence epoch lock is still held. A panic wipe
// can therefore happen before both operations or after both, never between.
applyAuthenticatedState(peerID, remoteStatic, state)
}
) return@synchronized false
current.timeoutJob?.cancel()
current.status = AuthenticatedPeerStateStatus.Proven(state)
if (!current.echoSent) {
current.echoSent = true
shouldEcho = true
echoSession = current.authenticatedSession
}
true
} catch (_: Exception) {
false
}
}
}
if (!accepted) return false
if (shouldEcho) {
val exactSession = echoSession ?: return false
runCatching { sendState(peerID, localStateProvider(), exactSession) }
}
onResolution(peerID)
return true
}
fun status(
peerID: String,
authenticatedSession: AuthenticatedNoiseSession
): AuthenticatedPeerStateStatus {
ensureSession(peerID, authenticatedSession)
val currentFingerprint = fingerprint(authenticatedSession.remoteStaticKey)
return synchronized(lock) {
sessions[peerID]?.takeIf {
it.fingerprint == currentFingerprint &&
it.authenticatedSession == authenticatedSession
}?.status
?: AuthenticatedPeerStateStatus.Missing
}
}
fun persistedSigningKeyFor(noisePublicKey: ByteArray): ByteArray? {
if (noisePublicKey.size != 32) return null
return store.load(fingerprint(noisePublicKey))?.signingPublicKey?.copyOf()
}
fun isPrivateMediaPinned(peerID: String): Boolean {
val authenticatedSession = authenticatedSessionProvider(peerID) ?: return false
return store.isPrivateMediaPinned(fingerprint(authenticatedSession.remoteStaticKey))
}
fun clear(peerID: String) {
synchronized(lock) { sessions.remove(peerID)?.timeoutJob?.cancel() }
}
private fun fingerprint(publicKey: ByteArray): String =
MessageDigest.getInstance("SHA-256")
.digest(publicKey)
.joinToString("") { "%02x".format(it) }
}

View File

@ -1,18 +0,0 @@
package com.bitchat.android.mesh
import com.bitchat.android.protocol.MessageType
/**
* iOS-compatible BLE padding policy.
*
* Keep this aligned with iOS BLEOutboundPacketPolicy.padsBLEFrame(for:):
* only Noise frames are padded over BLE.
*/
object BLEPacketPaddingPolicy {
fun shouldPadForBLE(type: UByte): Boolean {
return when (MessageType.fromValue(type)) {
MessageType.NOISE_ENCRYPTED, MessageType.NOISE_HANDSHAKE -> true
else -> false
}
}
}

View File

@ -18,7 +18,7 @@ class BluetoothConnectionManager(
private val context: Context,
private val myPeerID: String,
private val fragmentManager: FragmentManager? = null
) {
) : PowerManagerDelegate {
companion object {
private const val TAG = "BluetoothConnectionManager"
@ -30,7 +30,7 @@ class BluetoothConnectionManager(
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
// Power management
private val powerManager = PowerManager.getInstance(context.applicationContext)
private val powerManager = PowerManager(context.applicationContext)
// Coroutines
private val connectionScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -42,12 +42,8 @@ class BluetoothConnectionManager(
// Delegate for component managers to call back to main manager
private val componentDelegate = object : BluetoothConnectionManagerDelegate {
override fun onPacketReceived(
packet: BitchatPacket,
peerID: String,
device: BluetoothDevice?,
ingressLinkID: String
) {
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) {
Log.d(TAG, "onPacketReceived: Packet received from ${device?.address} ($peerID)")
device?.let { bluetoothDevice ->
// Get current RSSI for this device and update if available
val currentRSSI = connectionTracker.getBestRSSI(bluetoothDevice.address)
@ -58,7 +54,7 @@ class BluetoothConnectionManager(
if (peerID == myPeerID) return // Ignore messages from self
delegate?.onPacketReceived(packet, peerID, device, ingressLinkID)
delegate?.onPacketReceived(packet, peerID, device)
}
override fun onDeviceConnected(device: BluetoothDevice) {
@ -67,17 +63,8 @@ class BluetoothConnectionManager(
delegate?.onDeviceConnected(device)
}
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 onDeviceDisconnected(device: BluetoothDevice) {
delegate?.onDeviceDisconnected(device)
}
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
@ -101,12 +88,6 @@ class BluetoothConnectionManager(
// Public property for address-peer mapping
val addressPeerMap get() = connectionTracker.addressPeerMap
fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
connectionTracker.observePeerIfCurrent(deviceAddress, linkID, peerID)
fun getCurrentLinkID(deviceAddress: String): String? =
connectionTracker.getCurrentLinkID(deviceAddress)
private fun isBleTransportEnabled(): Boolean {
return try {
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
@ -126,19 +107,7 @@ class BluetoothConnectionManager(
}
init {
connectionScope.launch {
var previousMode: PowerManager.PowerMode? = null
powerManager.profile.collect { profile ->
val modeChanged = previousMode != null && previousMode != profile.mode
previousMode = profile.mode
if (!isActive || !isBleTransportEnabled()) return@collect
if (modeChanged && isGattServerEnabled()) {
serverManager.restartAdvertising()
}
clientManager.applyPowerProfile(profile)
}
}
powerManager.delegate = this
// Observe debug settings to enforce role state while active
try {
val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
@ -203,8 +172,10 @@ class BluetoothConnectionManager(
toEvict.forEach { conn ->
if (conn.isClient) {
Log.d(TAG, "Evicting client ${conn.device.address}")
try { conn.gatt?.disconnect() } catch (_: Exception) { }
} else {
Log.d(TAG, "Evicting server ${conn.device.address}")
serverManager.disconnectDevice(conn.device)
}
}
@ -218,6 +189,8 @@ class BluetoothConnectionManager(
* Start all Bluetooth services with power optimization
*/
fun startServices(): Boolean {
Log.i(TAG, "Starting power-optimized Bluetooth services...")
if (!isBleTransportEnabled()) {
Log.i(TAG, "BLE transport disabled by debug settings; not starting Bluetooth services")
disableTransport()
@ -236,6 +209,7 @@ class BluetoothConnectionManager(
try {
isActive = true
Log.d(TAG, "ConnectionManager activated (permissions and adapter OK)")
// set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable
// try {
@ -265,6 +239,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false
return@launch
}
Log.d(TAG, "GATT Server started")
} else {
Log.i(TAG, "GATT Server disabled by debug settings; not starting")
}
@ -275,6 +250,7 @@ class BluetoothConnectionManager(
this@BluetoothConnectionManager.isActive = false
return@launch
}
Log.d(TAG, "GATT Client started")
} else {
Log.i(TAG, "GATT Client disabled by debug settings; not starting")
}
@ -308,13 +284,19 @@ class BluetoothConnectionManager(
* Stop all Bluetooth services with proper cleanup
*/
fun stopServices() {
Log.i(TAG, "Stopping power-optimized Bluetooth services")
isActive = false
connectionScope.launch {
Log.d(TAG, "Stopping client/server and power components...")
// Stop component managers
clientManager.stop()
serverManager.stop()
// Stop power manager
powerManager.stop()
// Stop connection tracker
connectionTracker.stop()
@ -330,27 +312,21 @@ class BluetoothConnectionManager(
* Returns false if its coroutine scope has been cancelled.
*/
fun isReusable(): Boolean {
return connectionScope.isActive
val active = connectionScope.isActive
if (!active) {
Log.d(TAG, "BluetoothConnectionManager isReusable=false (scope cancelled)")
}
return active
}
/**
* Broadcast packet to connected devices with connection limit enforcement
* Automatically fragments large packets to fit within BLE MTU limits
*/
fun broadcastPacket(routed: RoutedPacket): Boolean {
if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.broadcastPacket(
routed,
serverManager.getGattServer(),
serverManager.getCharacteristic()
)
}
suspend fun broadcastControlPacketAndAwaitAcceptance(routed: RoutedPacket): Boolean {
if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.broadcastControlPacketAndAwaitAcceptance(
fun broadcastPacket(routed: RoutedPacket) {
if (!isActive || !isBleTransportEnabled()) return
packetBroadcaster.broadcastPacket(
routed,
serverManager.getGattServer(),
serverManager.getCharacteristic()
@ -383,17 +359,6 @@ class BluetoothConnectionManager(
serverManager.getCharacteristic()
)
}
fun sendPacketToLink(deviceAddress: String, linkID: String, packet: BitchatPacket): Boolean {
if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.sendPacketToLink(
RoutedPacket(packet),
deviceAddress,
linkID,
serverManager.getGattServer(),
serverManager.getCharacteristic()
)
}
// Expose role controls for debug UI
@ -479,21 +444,65 @@ class BluetoothConnectionManager(
}
}
// MARK: - PowerManagerDelegate Implementation
override fun onPowerModeChanged(newMode: PowerManager.PowerMode) {
Log.i(TAG, "Power mode changed to: $newMode")
connectionScope.launch {
if (!isActive || !isBleTransportEnabled()) {
serverManager.stop()
clientManager.stop()
return@launch
}
// Avoid rapid scan restarts by checking if we need to change scan behavior
val wasUsingDutyCycle = powerManager.shouldUseDutyCycle()
// Update advertising with new power settings if server enabled
val serverEnabled = isGattServerEnabled()
if (serverEnabled) {
serverManager.restartAdvertising()
} else {
serverManager.stop()
}
// Only restart scanning if the duty cycle behavior changed
val nowUsingDutyCycle = powerManager.shouldUseDutyCycle()
if (wasUsingDutyCycle != nowUsingDutyCycle) {
Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan")
val clientEnabled = isGattClientEnabled()
if (clientEnabled) {
clientManager.restartScanning()
} else {
clientManager.stop()
}
} else {
Log.d(TAG, "Duty cycle behavior unchanged, keeping existing scan state")
}
// Enforce connection limits
enforceStrictLimits()
}
}
override fun onScanStateChanged(shouldScan: Boolean) {
if (!isActive || !isBleTransportEnabled()) {
clientManager.onScanStateChanged(false)
return
}
clientManager.onScanStateChanged(shouldScan)
}
// MARK: - Private Implementation - All moved to component managers
}
/**
* Delegate interface for Bluetooth connection manager callbacks
*/
interface BluetoothConnectionManagerDelegate {
fun onPacketReceived(
packet: BitchatPacket,
peerID: String,
device: BluetoothDevice?,
ingressLinkID: String
)
fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?)
fun onDeviceConnected(device: BluetoothDevice)
fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?)
fun onDeviceDisconnected(device: BluetoothDevice)
fun onRSSIUpdated(deviceAddress: String, rssi: Int)
fun onGattClientWriteComplete(deviceAddress: String, linkID: String, status: Int) = Unit
fun onGattServerNotificationComplete(deviceAddress: String, linkID: String?, status: Int) = Unit
}

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