Compare commits

..

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

623 changed files with 10221 additions and 116414 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

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

View File

@ -10,11 +10,11 @@ permissions:
jobs: jobs:
update-relay-data: update-relay-data:
runs-on: ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 uses: actions/checkout@v4
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
@ -37,4 +37,4 @@ jobs:
git commit -m "Automated update of relay data - $(date -u)" git commit -m "Automated update of relay data - $(date -u)"
git push git push
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -2,118 +2,99 @@ name: Release
on: on:
workflow_dispatch: workflow_dispatch:
inputs:
tag:
description: Existing vX.Y.Z tag to build and attest
required: true
type: string
push: push:
tags: tags:
- "v*.*.*" - 'v*.*.*' # Triggers for tags like v1.0.0
permissions:
contents: read
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
jobs: jobs:
build: build:
name: Build unsigned release ${{ matrix.replica }} runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
replica: [a, b]
steps: 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 APK
run: ./gradlew assembleRelease --no-daemon --stacktrace
- name: List APK files
run: | run: |
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then echo "APK files built:"
echo "error: release tag must look like vX.Y.Z" >&2 find app/build/outputs/apk/release -name "*.apk" -type f
exit 1
fi
- name: Checkout tagged source - name: Rename APK
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
ref: ${{ env.RELEASE_TAG }}
- name: Verify workflow provenance ref
run: | run: |
checked_out_commit="$(git rev-parse HEAD)" mv app/build/outputs/apk/release/app-release-unsigned.apk app/build/outputs/apk/release/bitchat.apk
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 - name: DEBUG
exit 1 run: |
fi set -x
- name: Validate the Gradle wrapper pwd
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
ls -all
cd app/build/outputs/
ls -all
tree
# Optional: Sign APK (requires secrets)
# - name: Sign APK
# uses: r0adkll/sign-android-release@v1
# with:
# releaseDirectory: app/build/outputs/apk/release
# signingKeyBase64: ${{ secrets.SIGNING_KEY }}
# alias: ${{ secrets.ALIAS }}
# keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }}
# keyPassword: ${{ secrets.KEY_PASSWORD }}
- name: Upload APK as artifact
uses: actions/upload-artifact@v4
with: with:
cache-disabled: true name: bitchat-release-apk-${{ github.ref_name }}
path: app/build/outputs/apk/release/*.apk
- name: Build canonical unsigned APKs and AAB
run: tools/reproducible-builds/build-in-container.sh "$RUNNER_TEMP/release-${{ matrix.replica }}"
- name: Upload unsigned replica
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: unsigned-release-${{ matrix.replica }}
path: ${{ runner.temp }}/release-${{ matrix.replica }}/*
retention-days: 30 retention-days: 30
if-no-files-found: error if-no-files-found: error
compare: release:
name: Verify reproducibility
runs-on: ubuntu-24.04
needs: build needs: build
permissions: runs-on: ubuntu-latest
attestations: write
contents: read
id-token: write
steps: steps:
- name: Checkout verification script - name: Download APK artifact
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 uses: actions/download-artifact@v4
with: with:
ref: ${{ env.RELEASE_TAG }} name: bitchat-release-apk-${{ github.ref_name }}
path: .
- name: Download replica A - name: Create GitHub Release
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 uses: softprops/action-gh-release@v2
with: with:
name: unsigned-release-a files: bitchat.apk
path: ${{ runner.temp }}/release-a name: Release ${{ github.ref_name }}
env:
- name: Download replica B GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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

20
.gitignore vendored
View File

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

View File

@ -1 +0,0 @@
21.0.11

127
AGENTS.md
View File

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

171
CHANGELOG.md Normal file
View File

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

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.

View File

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

View File

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

338
README.md
View File

@ -1,29 +1,25 @@
<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
[<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) You can download the latest version of bitchat for Android from the [GitHub Releases page](https://github.com/permissionlesstech/bitchat-android/releases).
## See it in action **Instructions:**
<table> 1. **Download the APK:** On your Android device, navigate to the link above and download the latest `.apk` file. Open it.
<tr> 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**.
<th>Offline mesh conversation</th> 3. **Install:** Open the downloaded `.apk` file to begin the installation.
<th>Geohash globe picker</th>
</tr>
<tr>
<td><img src="docs/screenshots/readme-mesh-chat.png" alt="Active four-peer Bitchat mesh conversation with an image, voice messages, and text messages" width="360"/></td>
<td><img src="docs/screenshots/readme-geohash-globe.png" alt="Bitchat geohash location picker showing the whole Earth and geohash grid" width="360"/></td>
</tr>
</table>
## License ## License
@ -31,75 +27,277 @@ This project is released into the public domain. See the [LICENSE](LICENSE.md) f
## Features ## Features
- **Dual Transport Architecture**: Bluetooth LE mesh for offline messaging, Nostr relays for internet-based messaging - **✅ Cross-Platform Compatible**: Full protocol compatibility with iOS bitchat
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over Nostr relays - **✅ Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **Intelligent Message Routing**: Automatically chooses the best transport, with queuing and retry when a peer is unreachable - **✅ End-to-End Encryption**: X25519 key exchange + AES-256-GCM for private messages
- **End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) (XX pattern, X25519 + ChaCha20-Poly1305) for private messages over the mesh - **✅ Channel-Based Chats**: Topic-based group messaging with optional password protection
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop relay over Bluetooth LE (max 7 hops) - **✅ Store & Forward**: Messages cached for offline peers and delivered when they reconnect
- **Wi-Fi Aware Transport**: Higher-bandwidth local mesh on supported devices - **✅ Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **Channel Chats**: Topic-based group messaging with optional password protection (Argon2id + AES-256-GCM) - **✅ IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface
- **IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface - **✅ Message Retention**: Optional channel-wide message saving controlled by channel owners
- **Tor Support**: Built-in Tor (Arti) for private internet connectivity - **✅ Emergency Wipe**: Triple-tap logo to instantly clear all data
- **Emergency Wipe**: Triple-tap to instantly clear all data - **✅ Modern Android UI**: Jetpack Compose with Material Design 3
- **Cross-Platform**: Binary protocol compatible with bitchat on iOS and macOS - **✅ 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 - **Android Studio**: Arctic Fox (2020.3.1) or newer
- Noise Protocol sessions with forward secrecy; peer identities derived from static keys - **Android SDK**: API level 26 (Android 8.0) or higher
- Compact binary packet format with fragmentation, TTL routing, and deduplication - **Kotlin**: 1.8.0 or newer
- Adaptive duty cycling and connection limits for battery efficiency - **Gradle**: 7.0 or newer
- Foreground service keeps the mesh alive within Android background execution limits
### Nostr Protocol (Internet) ### Build Instructions
- Global reach via public relays, geohash-based location channels 1. **Clone the repository:**
- Private messages fall back to Nostr for mutual favorites when the mesh is unavailable ```bash
- Ephemeral keys per geohash area 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 3. **Build the project:**
- Coroutines and Flow for all networking and state ```bash
- Core components: `MeshForegroundService` (persistent connectivity), `BluetoothMeshService` / `WifiAwareMeshService` (transports), `UnifiedMeshService` (transport selection), `NoiseSessionManager` (encryption sessions), `MessageRouter` (mesh/Nostr routing with outbox retry) ./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 ```bash
git clone https://github.com/permissionlesstech/bitchat-android.git
cd bitchat-android
./gradlew assembleDebug ./gradlew assembleDebug
```
Install on a connected device:
```bash
adb install -r app/build/outputs/apk/debug/app-debug.apk 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 For production releases:
pinned Linux container. Maintainers should follow the
[Android release guide](docs/maintainer-release-guide.md). See
[Reproducible builds](docs/reproducible-builds.md) for the build trust model
and public GitHub/Google Play verification procedures.
## Testing
```bash ```bash
# Unit tests ./gradlew assembleRelease
./gradlew test
# Lint
./gradlew lint
# Instrumented tests (requires a device or emulator)
./gradlew connectedAndroidTest
``` ```
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
- **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 { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.compose) 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 { android {
namespace = "com.bitchat.android" namespace = "com.bitchat.android"
compileSdk = libs.versions.compileSdk.get().toInt() compileSdk = libs.versions.compileSdk.get().toInt()
buildToolsVersion = libs.versions.buildTools.get()
defaultConfig { defaultConfig {
applicationId = "com.bitchat.droid" applicationId = "com.bitchat.droid"
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = 38 versionCode = 19
versionName = "2.0.1" versionName = "1.2.3"
buildConfigField(
"String",
"GITHUB_RELEASE_CERT_SHA256",
"\"$normalizedGithubReleaseCertSha256\""
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@ -52,12 +30,6 @@ android {
} }
buildTypes { buildTypes {
debug {
ndk {
// Include x86_64 for emulator support during development
abiFilters += listOf("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
}
}
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
@ -65,39 +37,17 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
vcsInfo {
// BUILDINFO.json and attestations carry the verified commit
// without depending on host-specific Git/worktree paths.
include = false
}
} }
} }
// APK splits for GitHub releases - creates arm64, x86_64, and universal APKs
// AAB for Play Store handles architecture distribution automatically
// Auto-detects: splits enabled for assemble tasks, disabled for bundle tasks
// Works in Android Studio GUI and CLI without needing extra properties
val enableSplits = gradle.startParameter.taskNames.any { taskName ->
taskName.contains("assemble", ignoreCase = true) &&
!taskName.contains("bundle", ignoreCase = true)
}
splits {
abi {
isEnable = enableSplits
reset()
include("arm64-v8a", "x86_64", "armeabi-v7a", "x86")
isUniversalApk = true // For F-Droid and fallback
}
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
packaging { packaging {
resources { resources {
@ -111,20 +61,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 { dependencies {
// Core Android dependencies // Core Android dependencies
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
@ -137,22 +73,12 @@ dependencies {
// Lifecycle // Lifecycle
implementation(libs.bundles.lifecycle) implementation(libs.bundles.lifecycle)
implementation(libs.androidx.lifecycle.process)
// Navigation // Navigation
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
// Permissions // Permissions
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
// QR
implementation(libs.zxing.core)
implementation(libs.mlkit.barcode.scanning)
// CameraX
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.compose)
// Cryptography // Cryptography
implementation(libs.bundles.cryptography) implementation(libs.bundles.cryptography)
@ -169,17 +95,8 @@ dependencies {
// WebSocket // WebSocket
implementation(libs.okhttp) implementation(libs.okhttp)
// WorkManager for background APK downloads // Arti (Tor in Rust) Android bridge - use published AAR with native libs
implementation(libs.androidx.work.runtime.ktx) implementation("info.guardianproject:arti-mobile-ex:1.2.3")
// HTTP Server for hotspot APK sharing
implementation(libs.nanohttpd)
// Arti (Tor in Rust) Android bridge - custom build from latest source
// Built with rustls, 16KB page size support, and onio//un service client
// Native libraries are in src/tor/jniLibs/ (extracted from arti-custom.aar)
// Only included in tor flavor to reduce APK size for standard builds
// Note: AAR is kept in libs/ for reference, but libraries loaded from jniLibs/
// Google Play Services Location // Google Play Services Location
implementation(libs.gms.location) implementation(libs.gms.location)
@ -187,21 +104,9 @@ dependencies {
// Security preferences // Security preferences
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
// EXIF orientation handling for images
implementation(libs.androidx.exifinterface)
// Testing // Testing
testImplementation(libs.bundles.testing) testImplementation(libs.bundles.testing)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.bundles.compose.testing) androidTestImplementation(libs.bundles.compose.testing)
debugImplementation(libs.androidx.compose.ui.tooling) 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

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

View File

@ -17,24 +17,9 @@
-keep class com.bitchat.android.nostr.** { *; } -keep class com.bitchat.android.nostr.** { *; }
-keep class com.bitchat.android.identity.** { *; } -keep class com.bitchat.android.identity.** { *; }
# Room loads generated database implementations by name and invokes their no-argument # Arti (Tor) ProGuard rules
# constructors reflectively. R8 full-mode can otherwise optimize away WorkDatabase_Impl's
# constructor, causing AndroidX Startup to crash before Application.onCreate.
-keepclassmembers class * extends androidx.room.RoomDatabase {
<init>();
}
# Keep Tor implementation (always included)
-keep class com.bitchat.android.net.RealTorProvider { *; }
# Arti (Custom Tor implementation in Rust) ProGuard rules
-keep class info.guardianproject.arti.** { *; } -keep class info.guardianproject.arti.** { *; }
-keep class org.torproject.arti.** { *; } -keep class org.torproject.jni.** { *; }
-keepnames class org.torproject.arti.** -keepnames class org.torproject.jni.**
-dontwarn info.guardianproject.arti.** -dontwarn info.guardianproject.arti.**
-dontwarn org.torproject.arti.** -dontwarn org.torproject.jni.**
# Fix for AbstractMethodError on API < 29 where LocationListener methods are abstract
-keepclassmembers class * implements android.location.LocationListener {
public <methods>;
}

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

@ -5,7 +5,6 @@
<!-- Internet permissions for Nostr relay connections --> <!-- Internet permissions for Nostr relay connections -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<!-- Bluetooth permissions --> <!-- Bluetooth permissions -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
@ -17,46 +16,10 @@
<!-- Location permission required for BLE scanning --> <!-- Location permission required for BLE scanning -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Notification permissions --> <!-- Notification permissions -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- WiFi / WiFi Aware permissions (also used for hotspot APK sharing) -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Android 13+ runtime permission for WiFi operations (including Aware and WiFi P2P) -->
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- Android 17+ gates local network access; WiFi Aware peers over link-local IPv6 -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Keep hotspot alive while sharing the APK -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Signature permission for internal UI shutdown broadcasts -->
<uses-permission android:name="com.bitchat.android.permission.FORCE_FINISH" />
<!-- Foreground service and boot permissions for long-running background mesh -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- Connected device foreground service type for BLE operations (API 34+) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<!-- Data sync foreground service type (required when declaring dataSync) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Location foreground service type required for BLE scanning in background -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Microphone for voice notes -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Camera for QR verification -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Storage permissions for file sharing -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- Haptic feedback permission --> <!-- Haptic feedback permission -->
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
@ -66,13 +29,6 @@
<!-- Hardware features --> <!-- Hardware features -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" /> <uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<uses-feature android:name="android.hardware.bluetooth" android:required="true" /> <uses-feature android:name="android.hardware.bluetooth" android:required="true" />
<!-- Device support hint for WiFi Aware (optional) -->
<uses-feature android:name="android.hardware.wifi.aware" android:required="false" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<permission
android:name="com.bitchat.android.permission.FORCE_FINISH"
android:protectionLevel="signature" />
<application <application
android:name=".BitchatApplication" android:name=".BitchatApplication"
@ -80,92 +36,29 @@
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.BitchatAndroid" android:theme="@style/Theme.BitchatAndroid"
tools:targetApi="33"> tools:targetApi="31">
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
<!-- FileProvider for sharing temp/cache files with external viewers -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity <activity
android:name=".ui.GeohashPickerActivity" android:name=".ui.GeohashPickerActivity"
android:exported="false" android:exported="false"
android:theme="@style/Theme.BitchatAndroid" android:theme="@style/Theme.BitchatAndroid"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.BitchatAndroid" android:theme="@style/Theme.BitchatAndroid"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:launchMode="singleTop"> android:launchMode="singleTop">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="bitchat" android:host="verify" />
</intent-filter>
</activity> </activity>
<!-- Persistent foreground service to run the mesh in background -->
<service
android:name=".service.MeshForegroundService"
android:exported="false"
android:foregroundServiceType="connectedDevice|dataSync|location"
tools:ignore="DataExtractionRules">
</service>
<receiver
android:name=".service.ConversationNotificationReceiver"
android:enabled="true"
android:exported="false" />
<!-- Auto-start mesh service after boot if enabled -->
<receiver
android:name=".service.BootCompletedReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
</intent-filter>
</receiver>
<!-- Hotspot Activity for offline APK sharing -->
<activity
android:name=".hotspot.HotspotActivity"
android:exported="false"
android:label="Share BitChat"
android:theme="@style/Theme.BitchatAndroid"
android:launchMode="singleTop" />
<!-- Declare the foreground service type for WorkManager's foreground
service so the APK download worker can run as dataSync work -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application> </application>
</manifest> </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,263 @@
Relay URL,Latitude,Longitude Relay URL,Latitude,Longitude
myvoiceourstory.org,37.3598,-121.981 relay.damus.io,43.6532,-79.3832
nostr.linky.fit,50.1109,8.68213 nostr2.girino.org,43.6532,-79.3832
public.obelisk.ar,43.6532,-79.3832 nostr.n7ekb.net,47.4941,-122.294
relay.i9.eti.br,43.6532,-79.3832 rnostr.breadslice.com,43.6532,-79.3832
yabu.me,35.6092,139.73 portal-relay.pareto.space,49.4543,11.0746
relay-us.zombi.cloudrodion.com,40.7862,-74.0743 relay.hasenpfeffr.com,39.0438,-77.4874
relay.nostu.be,40.4167,-3.70329 nostr.blankfors.se,60.1699,24.9384
relay.favillakey.io,-27.4705,153.026 relay5.bitransfer.org,43.6532,-79.3832
nostr.mikoshi.de,50.1109,8.68213 santo.iguanatech.net,40.8302,-74.1299
relay.agilesolutionlabs.com,43.6532,-79.3832 nostr.ser1.net,12.9716,77.5946
nostr.sovereignservices.xyz,43.6532,-79.3832 nostr.stakey.net,52.3676,4.90414
relay.qstr.app,50.1109,8.68213 relayrs.notoshi.win,43.6532,-79.3832
articles.layer3.news:443,37.3387,-121.885 orangepiller.org,60.1699,24.9384
relay.nostrops.com,32.71,-96.6745 temp.iris.to,43.6532,-79.3832
relay.mrmave.work,43.6532,-79.3832 relay.cypherflow.ai,48.8566,2.35222
nostr.islandarea.net:443,35.4669,-97.6473 nostr.kalf.org,52.3676,4.90414
nostr.azzamo.net:443,52.2633,21.0283 relay.unknown.cloud,43.6532,-79.3832
ec2.f7z.io,60.1699,24.9384 relay.artx.market,43.652,-79.3633
relay.veganostr.com,60.1699,24.9384 relay.arx-ccn.com,50.4754,12.3683
nostr.dlcdevkit.com,40.0992,-83.1141 relay.electriclifestyle.com,26.2897,-80.1293
relay.44billion.net,43.6532,-79.3832 nostr-relay.online,43.6532,-79.3832
bendernostur.duckdns.org:8443,50.1109,8.68213 relay.jeffg.fyi,43.6532,-79.3832
nostras.app,38.7946,-106.535 relay.wellorder.net,45.5201,-122.99
cs-relay.nostrdev.com:443,50.4754,12.3683 relay01.lnfi.network,39.0997,-94.5786
nostr.stakey.net,52.3676,4.90414 noxir.kpherox.dev,34.8587,135.509
support.flotilla.social,32.9483,-96.7299 wot.sebastix.social,51.8933,4.42083
relay.ordoplay.com,50.1109,8.68213 nostr.overmind.lol,43.6532,-79.3832
nostr.stakey.net:443,52.3676,4.90414 nostr.red5d.dev,43.6532,-79.3832
relay.nostrmap.net,60.1699,24.9384 nostr.zenon.network,43.5009,-70.4428
relayone.geektank.ai,39.0997,-94.5786 cyberspace.nostr1.com,40.7128,-74.006
relay.novospes.com,43.6532,-79.3832 fenrir-s.notoshi.win,43.6532,-79.3832
relay.dyne.org,49.0291,8.35705 no.str.cr,9.92857,-84.0528
nostrrelay.taylorperron.com,45.5029,-73.5723 relay.nostrdice.com,-33.8688,151.209
nostrbtc.com,43.6532,-79.3832 nostr.data.haus,50.4754,12.3683
relay.lanavault.space,60.1699,24.9384 relay.g1sms.fr,43.9432,2.07537
relay.bullishbounty.com:443,43.6532,-79.3832 relay.fundstr.me,42.3601,-71.0589
relay.layer.systems:443,49.0291,8.35695 nostr.thaliyal.com,40.8218,-74.45
vault.iris.to:443,43.6532,-79.3832 relay.mccormick.cx,52.3563,4.95714
relay.getsafebox.app:443,43.6532,-79.3832 nostr.makibisskey.work,43.6532,-79.3832
relay.wellorder.net,45.5201,-122.99 shu01.shugur.net,21.4902,39.2246
relay.scuba323.com,40.8218,-74.45 relay.angor.io,48.1046,11.6002
dm-test-strfry-discovery.samt.st,43.6532,-79.3832 nostr.primz.org,43.6532,-79.3832
temp.iris.to,43.6532,-79.3832 nostr-02.yakihonne.com,1.32123,103.695
relay.paulstephenborile.com,49.4543,11.0746 relay.freeplace.nl,52.3676,4.90414
relay.cyberguy.fyi,52.6907,4.8181 relay.snort.social,43.6532,-79.3832
nostr.wecsats.io:443,43.6532,-79.3832 nostr.rohoss.com,50.1109,8.68213
bread.nostrsms.com,40.8218,-74.45 relay.getsafebox.app,43.6532,-79.3832
relay.plebeian.market,50.1109,8.68213 nostr.88mph.life,43.6532,-79.3832
relay.laantungir.net,-19.4692,-42.5315 wot.sovbit.host,64.1466,-21.9426
nostr.overmind.lol,43.6532,-79.3832 nostr-02.dorafactory.org,1.35208,103.82
nip85.nosfabrica.com,39.0997,-94.5786 itanostr.space,52.2931,4.79099
relay.conduit.market,38.7946,-106.535 ribo.af.nostria.app,-26.2041,28.0473
relay.sigit.io,50.4754,12.3683 nostr.azzamo.net,52.2633,21.0283
relay.dreamith.to,43.6532,-79.3832 relay.fountain.fm,39.0997,-94.5786
relay.decentralia.fr,48.122,11.589 relay.holzeis.me,43.6532,-79.3832
nexus.libernet.app,43.6532,-79.3832 relay.sincensura.org,43.6532,-79.3832
relay.nmail.li,50.9871,2.12554 nostr-dev.wellorder.net,45.5201,-122.99
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 relay.hodl.ar,-32.94,-60.6745
nostr-relay.nextblockvending.com,47.2343,-119.853 nostr-relay.psfoundation.info,39.0438,-77.4874
nostr-relay.corb.net,39.6478,-104.988 relay.goodmorningbitcoin.com,43.6532,-79.3832
relay.agentry.com,42.8864,-78.8784 nostr.rtvslawenia.com,49.4543,11.0746
budabit.nostr1.com,40.7057,-74.0136 relay.13room.space,43.6532,-79.3832
relay.cypherflow.ai,48.8575,2.35138 nostr-02.czas.top,53.471,9.88208
purplerelay.com,43.6532,-79.3832 relay.chorus.community,50.1109,8.68213
nostr.islandarea.net,35.4669,-97.6473 shu04.shugur.net,25.2604,55.2989
relay.edufeed.org,49.4521,11.0767 satsage.xyz,37.3986,-121.964
relay.bnos.space,43.6532,-79.3832 relay.bullishbounty.com,43.6532,-79.3832
relay.kizuna-miki.com,35.694,139.754 nostr.sagaciousd.com,49.2827,-123.121
nostr.snowbla.de,50.4754,12.3683 relay.nostromo.social,49.4543,11.0746
bitchat.nostr1.com,40.7057,-74.0136 relay.magiccity.live,25.8128,-80.2377
relay.beamhop.com,43.6532,-79.3832 relay-dev.satlantis.io,40.8302,-74.1299
relay.wavefunc.live,41.8781,-87.6298 nostr.coincrowd.fund,39.0438,-77.4874
relayrs.notoshi.win,43.6532,-79.3832 shu05.shugur.net,48.8566,2.35222
relay.nostrhub.fr,48.1045,11.6004 nostrelay.memory-art.xyz,43.6532,-79.3832
relay01.lnfi.network,35.6764,139.65 nostr.now,36.55,139.733
relay.bitos.space,43.6532,-79.3832 nostr-pub.wellorder.net,45.5201,-122.99
relay.noeudlibre.fr,50.6924,3.20113 nos.lol,50.4754,12.3683
relay.orly.dev,32.7767,-96.797 wot.dergigi.com,64.1476,-21.9392
relay.paulstephenborile.com:443,49.4543,11.0746 inbox.azzamo.net,52.2633,21.0283
relay.atsocy.com,43.6532,-79.3832 nostr.mikoshi.de,50.1109,8.68213
relay.keykeeper.world,40.7824,-74.0711 librerelay.aaroniumii.com,43.6532,-79.3832
nostrmxn.lulus.com.mx,59.4016,17.9455 relay.nosto.re,51.8933,4.42083
maxq.descendant.io,43.6532,-79.3832 relay.lumina.rocks,49.0291,8.35695
nostr.spicyz.io:443,43.6532,-79.3832 kitchen.zap.cooking,43.6532,-79.3832
nostr.chaima.info:443,50.1109,8.68213 zap.watch,45.5029,-73.5723
relay.opmaat.org,60.1699,24.9384 nostr.spacecitynode.com,29.7057,-95.2706
nostr.notribe.net,40.8302,-74.1299 nostr.bilthon.dev,25.8128,-80.2377
nostr-01.yakihonne.com:443,1.32123,103.695 relay.aloftus.io,34.0881,-118.379
offchain.pub,39.1585,-94.5728 shu02.shugur.net,21.4902,39.2246
relay.agora.social,50.7383,15.0648 relay.mwaters.net,50.9871,2.12554
relay.tdw.lol,41.8781,-87.6298 relay.moinsen.com,50.4754,12.3683
nostr.nodesmap.com,59.3327,18.0656 nostr.kungfu-g.rip,33.7946,-84.4488
relay.getvia.xyz,60.1699,24.9384 nostr.sathoarder.com,48.5734,7.75211
public.crostr.com,43.6532,-79.3832 nostream.breadslice.com,43.6532,-79.3832
relay.agorist.space,52.3734,4.89406 slick.mjex.me,39.048,-77.4817
relay.lacrypta.ar,43.6532,-79.3832 nostr.jerrynya.fun,31.2304,121.474
nostr.spaceshell.xyz,43.6532,-79.3832 articles.layer3.news,37.3387,-121.885
relay.nuts.cash,52.3676,4.90414 nostr.satstralia.com,64.1476,-21.9392
relay.otrta.me,50.1109,8.68213 relay.degmods.com,50.4754,12.3683
relay.satmaxt.xyz,43.6532,-79.3832 relay.0xchat.com,1.35208,103.82
relay.earthly.city,34.1749,-118.54 relay.nostx.io,43.6532,-79.3832
relay.wisp.talk:443,49.4543,11.0746 relay.siamdev.cc,13.9178,100.424
mostro-p2p.tech,50.1109,8.68213 relay.mattybs.lol,43.6532,-79.3832
nostr.thalheim.io,60.1699,24.9384 relay.bitcoinveneto.org,64.1466,-21.9426
relay.s-w.art,43.6532,-79.3832 nostr.tadryanom.me,43.6532,-79.3832
nostr.chaima.info,50.1109,8.68213 nostr.spaceshell.xyz,43.6532,-79.3832
articles.layer3.news,37.3387,-121.885 relay.nostr.band,60.1699,24.9384
nostr2.girino.org:443,43.6532,-79.3832 relay03.lnfi.network,39.0997,-94.5786
relay.mostr.pub:443,43.6532,-79.3832 schnorr.me,43.6532,-79.3832
relay.ditto.pub:443,43.6532,-79.3832 khatru.nostrver.se,51.8933,4.42083
relay.manneken.brussels,49.4543,11.0746 dealtime-footwear-angry-records.trycloudflare.com,43.6532,-79.3832
relay.kaleidoswap.com,50.8476,4.35717 relay.endfiat.money,43.6532,-79.3832
nostrelay.circum.space,52.2245,8.826 internationalright-wing.org,-22.5022,-48.7114
relay.beginningend.com,35.2227,-97.4786 nostr.mom,50.4754,12.3683
relay-dev.gulugulu.moe,43.6532,-79.3832 nostrue.com,40.8054,-74.0241
relay.fundstr.me,42.3601,-71.0589 relay.chakany.systems,43.6532,-79.3832
relay.jmoose.rocks:443,37.4419,-122.143 vidono.apps.slidestr.net,48.8566,2.35222
relay.nostr.dev.br,48.1046,11.6002 relay.stream.labs.h3.se,59.4016,17.9455
relay.minibolt.info,43.6532,-79.3832 gnostr.com,40.9017,29.1616
nostr.unkn0wn.world,46.8499,9.53287 relay.origin.land,35.6673,139.751
vault.iris.to,43.6532,-79.3832 relay.sigit.io,50.4754,12.3683
relay.guggero.org,46.5971,9.59652 theoutpost.life,64.1476,-21.9392
relay.getsafebox.app,43.6532,-79.3832 adre.su,59.9311,30.3609
relay.bitmacro.cloud,43.6532,-79.3832 soloco.nl,43.6532,-79.3832
relay.wavlake.com,41.2619,-95.8608 wot.soundhsa.com,34.0479,-118.256
nostr.hekster.org:443,37.3986,-121.964 nos.xmark.cc,50.6924,3.20113
relay.lightning.pub,39.0438,-77.4874 nostr-relay.zimage.com,34.282,-118.439
relay-dev.gulugulu.moe:443,43.6532,-79.3832 nostr.pleb.one,38.6327,-90.1961
soloco.nl,43.6532,-79.3832 purpura.cloud,43.6532,-79.3832
nostr.hifish.org,47.4009,8.57814 ribo.us.nostria.app,41.5868,-93.625
relay-can.zombi.cloudrodion.com,43.6532,-79.3832 relay.digitalezukunft.cyou,45.5019,-73.5674
dm-test-strfry-generic.samt.st,43.6532,-79.3832 relay.mess.ch,47.3591,8.55292
relay.wavefunc.live:443,41.8781,-87.6298 relay.javi.space,43.4633,11.8796
porchlight.social,43.6532,-79.3832 nostr.jfischer.org,49.0291,8.35696
relay.zone667.com,60.1699,24.9384 nostr.rblb.it,43.4633,11.8796
nostr.data.haus:443,50.4754,12.3683 relay.nostriot.com,41.5695,-83.9786
relay.nexterz.com,43.6532,-79.3832 nostr.21crypto.ch,47.4988,8.72369
relay.sigit.io:443,50.4754,12.3683 relay.nostr.vet,52.6467,4.7395
nostr.girino.org,43.6532,-79.3832 fanfares.nostr1.com,40.7057,-74.0136
nostr.liberty.fans,36.8767,-89.5879 relay.copylaradio.com,51.223,6.78245
wheat.happytavern.co,43.6532,-79.3832 relay.mostro.network,40.8302,-74.1299
relay.0xchat.com,43.6532,-79.3832 nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
inbox.scuba323.com,40.8218,-74.45 relay.lifpay.me,1.35208,103.82
nostr.vulpem.com,49.4543,11.0746 nostr.tac.lol,47.4748,-122.273
relay.damustr.com,43.6532,-79.3832 relay.nostraddress.com,43.6532,-79.3832
no.str.cr:443,8.96171,-83.5246 wot.sudocarlos.com,51.5072,-0.127586
relay.vrtmrz.net:443,43.6532,-79.3832 nostr.spicyz.io,43.6532,-79.3832
vm-1734.lnvps.cloud,53.3498,-6.26031 relay02.lnfi.network,39.0997,-94.5786
testnet.samt.st,43.6532,-79.3832 nostr.4rs.nl,49.0291,8.35696
nostr.rtvslawenia.com:443,49.4543,11.0746 relay.21e6.cz,50.1682,14.0546
nostr.sathoarder.com:443,48.5734,7.75211 nostr.casa21.space,43.6532,-79.3832
relay.cosmicbolt.net,37.3986,-121.964 wot.codingarena.top,50.4754,12.3683
relay.staging.plebeian.market,51.5072,-0.127586 nostr-01.yakihonne.com,1.32123,103.695
relayone.soundhsa.com,39.0997,-94.5786 strfry.openhoofd.nl,51.9229,4.40833
relay.olas.app:443,60.1699,24.9384 nostr.plantroon.com,50.1013,8.62643
relay.illuminodes.com,43.6532,-79.3832 dev-nostr.bityacht.io,25.0797,121.234
relay.chorus.community:443,48.5333,10.7 ribo.eu.nostria.app,52.3676,4.90414
relay.ohstr.com:443,43.6532,-79.3832 wot.nostr.place,30.2672,-97.7431
fanfares.nostr1.com:443,40.7057,-74.0136 relay.dwadziesciajeden.pl,52.2297,21.0122
nostr.planix.org,43.6532,-79.3832 prl.plus,55.7623,37.6381
schnorr.me,43.6532,-79.3832 relay.bitcoinartclock.com,50.4754,12.3683
relay.mostro.network,40.8302,-74.1299 nostr.2b9t.xyz,34.0549,-118.243
nostr-verified.wellorder.net,45.5201,-122.99 a.nos.lol,50.4754,12.3683
ynostr.yael.at,60.1699,24.9384 relay.wavlake.com,41.2619,-95.8608
nostr.wild-vibes.ts.net,48.8566,2.35222 relay.toastr.net,40.8054,-74.0241
relay.mypathtofire.de,42.8864,-78.8784 wheat.happytavern.co,43.6532,-79.3832
relay.klabo.world,47.674,-122.122 relay.lnfi.network,39.0438,-77.4874
nostr.tagomago.me,42.3601,-71.0589 nostr.liberty.fans,36.9104,-89.5875
relay.veganostr.com:443,60.1699,24.9384 vitor.nostr1.com,40.7057,-74.0136
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786 nostr.thebiglake.org,32.71,-96.6745
relay.loveisbitcoin.com,43.6532,-79.3832 relay.exit.pub,50.4754,12.3683
bridge.tagomago.me,42.3601,-71.0589 freelay.sovbit.host,64.1476,-21.9392
relay-rpi.edufeed.org:443,49.4521,11.0767 purplerelay.com,50.1109,8.68213
relay.kilombino.com,43.6532,-79.3832 nostr.camalolo.com,24.1469,120.684
relay.sharegap.net,43.6532,-79.3832 black.nostrcity.club,41.8781,-87.6298
nostr.yutakobayashi.com,43.6532,-79.3832 relay.barine.co,43.6532,-79.3832
relay-na1.metanomalist.com,43.6532,-79.3832 nostr.0x7e.xyz,47.4988,8.72369
public.crostr.com:443,43.6532,-79.3832 relayone.soundhsa.com,34.0479,-118.256
nostrride.io,37.3986,-121.964 relay.vrtmrz.net,43.6532,-79.3832
nostr.bgbitcoin.club:443,50.4754,12.3683 nostr-03.dorafactory.org,1.35208,103.82
nostr.christiansass.de,50.1109,8.68213 r.bitcoinhold.net,43.6532,-79.3832
nos.lol:443,50.4754,12.3683 relay.agorist.space,52.3734,4.89406
relay.pyramid.li,47.4093,8.46503 nostr-relay.amethyst.name,39.0067,-77.4291
relay.mappingbitcoin.com,43.6532,-79.3832 relay-rpi.edufeed.org,49.4543,11.0746
nostr-relay.zimage.com,34.282,-118.439 orangesync.tech,50.1109,8.68213
offchain.pub:443,39.1585,-94.5728 relay04.lnfi.network,39.0997,-94.5786
nostr.myshosholoza.co.za:443,52.3676,4.90414 nostr.hifish.org,47.4043,8.57398
0x-nostr-relay.fly.dev,38.7946,-106.535 relay.nostr.wirednet.jp,34.706,135.493
relay1.privkey.io,25.7617,-80.1918 nostr.girino.org,43.6532,-79.3832
chat-relay.zap-work.com:443,43.6532,-79.3832 strfry.felixzieger.de,50.1013,8.62643
relay.staging.plebeian.market:443,51.5072,-0.127586 nostr.night7.space,50.4754,12.3683
nostr.robosats.org,64.1476,-21.9392 nostr.oxtr.dev,50.4754,12.3683
relay.endfiat.money:443,59.3327,18.0656 relay.nostrhub.fr,48.1046,11.6002
nostr.agh.ccwu.cc,43.6532,-79.3832 nostr.rikmeijer.nl,50.4754,12.3683
nostr.overmind.lol:443,43.6532,-79.3832 wot.basspistol.org,49.4521,11.0767
nostr.tac.lol,47.4748,-122.273 nostr.coincards.com,53.5501,-113.469
relay.ru.ac.th,13.7607,100.627 solo.itsalldance.space,32.71,-96.6745
nostr-relay.xbytez.io,50.6924,3.20113 relay.puresignal.news,43.6532,-79.3832
relay.islandbitcoin.com,12.8498,77.6545 nostrelay.circum.space,51.2217,6.77616
relay5.bitransfer.org,43.6532,-79.3832 relay.btcforplebs.com,43.6532,-79.3832
prl.plus,55.7628,37.5983 nostr.diakod.com,43.6532,-79.3832
chat-relay.zap-work.com,43.6532,-79.3832 tollbooth.stens.dev,51.223,6.78245
relay.satlantis.io,39.0438,-77.4874 relay.usefusion.ai,39.0438,-77.4874
nostr.bitcoiner.social:443,40.7608,-111.891 relay-admin.thaliyal.com,40.8218,-74.45
nostr.ac,38.958,-77.3592 nproxy.kristapsk.lv,60.1699,24.9384
relay.solomonstr.com,43.6532,-79.3832 nostr-verified.wellorder.net,45.5201,-122.99
strfry.apps3.slidestr.net,40.4167,-3.70329 wot.brightbolt.net,47.6735,-116.781
relay.nostr.com,50.1109,8.68213 relay.orangepill.ovh,49.1689,-0.358841
reraw.pbla2fish.cc,43.6532,-79.3832 relay.olas.app,50.4754,12.3683
nostr.bond,50.1109,8.68213 offchain.pub,36.1809,-115.241
relay.wavlake.com:443,41.2619,-95.8608 nostr.l484.com,30.2944,-97.6223
nos.lol,50.4754,12.3683 relay.nostrhub.tech,49.4543,11.0746
nostr.debate.report,50.1109,8.68213 relay.tapestry.ninja,40.8054,-74.0241
relay-testnet.k8s.layer3.news,37.3387,-121.885 nostr.snowbla.de,60.1699,24.9384
relay.lanacoin-eternity.com:443,40.8302,-74.1299 relay.letsfo.com,51.098,17.0321
relay.wisp.talk,49.4543,11.0746 relay.tagayasu.xyz,43.6715,-79.38
nostr.tabordalab.com,60.1699,24.9384 strfry.bonsai.com,37.8715,-122.273
relay.trotters.cc,43.6532,-79.3832 nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
nostr.4rs.nl,49.0291,8.35696 nostr.smut.cloud,43.6532,-79.3832
relay.hivetalk.org,40.8302,-74.1299 x.kojira.io,43.6532,-79.3832
spamspamspamspam.rest,43.6532,-79.3832 yabu.me,35.6092,139.73
nostr.janx.com,43.6532,-79.3832 relay.zone667.com,60.1699,24.9384
relay.mostr.pub,43.6532,-79.3832 relay.bitcoindistrict.org,43.6532,-79.3832
relay.lightning.pub:443,39.0438,-77.4874 pyramid.fiatjaf.com,50.1109,8.68213
relay.samt.st,40.8302,-74.1299 nostr.hekster.org,37.3986,-121.964
bruh.samt.st,43.6532,-79.3832 relay.laantungir.net,-19.4692,-42.5315
relay.orangesync.tech,40.7128,-74.006 relay.cosmicbolt.net,37.3986,-121.964
relay-fra.zombi.cloudrodion.com,48.8566,2.35222 strfry.shock.network,41.8959,-88.2169
relay.notoshi.win,13.3396,100.93 relay.coinos.io,43.6532,-79.3832
relay.nostreon.com,60.1699,24.9384 wot.nostr.net,43.6532,-79.3832
nostr.purpura.cloud,43.6532,-79.3832 nostr.einundzwanzig.space,50.1109,8.68213
basspistol.org,49.0291,8.35696 premium.primal.net,43.6532,-79.3832
relay.nostrfeed.com,60.1699,24.9384 dev-relay.lnfi.network,39.0997,-94.5786
nostr.dlcdevkit.com:443,40.0992,-83.1141 ynostr.yael.at,60.1699,24.9384
nostr.21crypto.ch,47.5356,8.73209 relay.fr13nd5.com,52.5233,13.3426
nostr.pbfs.io,50.4754,12.3683 nostr.lojong.info,43.6532,-79.3832
social.amanah.eblessing.co,48.1046,11.6002 srtrelay.c-stellar.net,43.6532,-79.3832
relay.cosmicbolt.net:443,37.3986,-121.964 nostr.myshosholoza.co.za,52.3676,4.90414
21milionidinostr.duckdns.org,41.8967,12.4822 alien.macneilmediagroup.com,43.6532,-79.3832
fanfares.nostr1.com,40.7057,-74.0136 relay.utxo.farm,35.6916,139.768
nostr.na.social,43.6532,-79.3832 relay.ru.ac.th,13.7584,100.622
nostr.data.haus,50.4754,12.3683 relay2.angor.io,48.1046,11.6002
strfry.shock.network:443,39.0438,-77.4874 relay.seq1.net,43.6532,-79.3832
nostr.pbfs.io:443,50.4754,12.3683 relay.illuminodes.com,47.6061,-122.333
relay.shadowbip.com,50.1109,8.68213 relay.satlantis.io,32.8769,-80.0114
relay.ltgnet.work:8443,51.0511,-114.075 relay.primal.net,43.6532,-79.3832
relay02.lnfi.network,35.6764,139.65 nostr.vulpem.com,49.4543,11.0746
nostr.myshosholoza.co.za,52.3676,4.90414 relay.evanverma.com,40.8302,-74.1299
relay.satsmarkt.club,52.6907,4.8181 nostr.chaima.info,51.223,6.78245
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 relay.notoshi.win,13.7829,100.546
auth.nostr1.com,40.7057,-74.0136 nostr.carroarmato0.be,50.9928,3.26317
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 relay.ditto.pub,43.6532,-79.3832
bitcoinostr.duckdns.org,38.9504,-0.14007 relay.credenso.cafe,43.1149,-80.7228
relayrs.notoshi.win:443,43.6532,-79.3832 relay.nostr.place,32.7767,-96.797
nostr.whitenode45.ddns.net,40.55,-74.4758 relay.conduit.market,43.6532,-79.3832
relay.mccormick.cx:443,52.3563,4.95714 nostr.huszonegy.world,47.4979,19.0402
nostr.2b9t.xyz:443,34.0549,-118.243 nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay.internationalright-wing.org,-22.4692,-48.9875 wot.nostr.party,36.1627,-86.7816
nostr.2b9t.xyz,34.0549,-118.243 wot.dtonon.com,43.6532,-79.3832
relay.mitchelltribe.com,39.0438,-77.4874 nostr.middling.mydns.jp,35.8099,140.12
relay.nostriot.com,41.5695,-83.9786 nostr-2.21crypto.ch,47.4988,8.72369
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832 nostr.dlsouza.lol,50.1109,8.68213
relay.sector01.com,41.4513,-81.7021
relay.nostx.io,43.6532,-79.3832
relay.fiatdenier.com,43.7787,-79.5393
relayone.soundhsa.com:443,39.0997,-94.5786
strfry.bonsai.com:443,39.0438,-77.4874
nostr.snowbla.de:443,50.4754,12.3683
relay.btcforplebs.com,43.6532,-79.3832
dev.relay.stream,43.6532,-79.3832
nostr-pub.wellorder.net,45.5201,-122.99
nostr.twinkle.lol,51.902,7.6657
nostr.carroarmato0.be,50.914,3.21378
bcast.girino.org,43.6532,-79.3832
treuzkas.branruz.com,48.8575,2.35138
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
relay.endfiat.money,59.3327,18.0656
relay.jmoose.rocks,37.4419,-122.143
relay.degmods.com,50.4754,12.3683
relay.ditto.pub,43.6532,-79.3832
nostrelay.circum.space:443,52.2245,8.826
relay.pocketnostr.com,40.8054,-74.0241
nostr.wecsats.io,43.6532,-79.3832
node.kommonzenze.de,49.4521,11.0767
nostr.sathoarder.com,48.5734,7.75211
chorus.mikedilger.com:444,-36.8906,174.794
cache.trustr.ing,43.6548,-79.3885
bitcoiner.social,40.7608,-111.891
relay.tdw.gg,34.0549,-118.243
relay.edufeed.org:443,49.4521,11.0767
relay.snotr.nl:49999,51.9758,4.31389
nostr.bgbitcoin.club,50.4754,12.3683
relay.bowlafterbowl.com,32.9483,-96.7299
relay-rpi.edufeed.org,49.4521,11.0767
x.kojira.io:443,43.6532,-79.3832
nostr.self-determined.de,51.4379,14.2355
schnorr.me:443,43.6532,-79.3832
nostr.computingcache.com,45.5341,-122.956
cs-relay.nostrdev.com,50.4754,12.3683
relay2.veganostr.com,60.1699,24.9384
relay.phostrich.com,40.55,-74.4758
relay.nostrian-conquest.com,41.223,-111.974
relay.flashapp.me,43.6548,-79.3885
relay.yoinekodo.jp,43.6532,-79.3832
relay-arg.zombi.cloudrodion.com,1.35208,103.82
freelay.sovbit.host,60.1699,24.9384
nostr.n7ekb.net,47.4941,-122.294
purplerelay.com:443,43.6532,-79.3832
nostr.tbxnetworx.de,47.7228,9.96456
relay.bnos.space:443,43.6532,-79.3832
relay.satsapp.me,50.4754,12.3683
nostr-01.uid.ovh,50.9871,2.12554
tribune-panel-growing-noon.trycloudflare.com,43.6532,-79.3832
nostr.mom,50.4754,12.3683
no.str.cr,8.96171,-83.5246
nostr.spicyz.io,43.6532,-79.3832
memlay.v0l.io,53.3498,-6.26031
bucket.coracle.social,37.7775,-122.397
nostr.azzamo.net,52.2633,21.0283
relay.lanacoin-eternity.com,40.8302,-74.1299
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
conduitl2.fly.dev,38.7946,-106.535
relay.wasabiwallet.io,43.6532,-79.3832
portal-relay.pareto.space,49.0291,8.35696
strfry.ymir.cloud,43.6532,-79.3832
nostr.hoppe-relay.it.com,42.8864,-78.8784
nostr.red5d.dev,43.6532,-79.3832
strfry.shock.network,39.0438,-77.4874
relay.chorus.community,48.5333,10.7
relay.mitchelltribe.com:443,39.0438,-77.4874
relay.olas.app,60.1699,24.9384
testnet-relay.samt.st:443,40.8302,-74.1299
relay.dwadziesciajeden.pl,52.2297,21.0122
relay.gulugulu.moe,43.6532,-79.3832
relay.underorion.se,50.1109,8.68213
relay.mccormick.cx,52.3563,4.95714
top.testrelay.top,43.6532,-79.3832
nostr-02.uid.ovh,50.9871,2.12554
nostr.relay.hedwig.sh,60.1699,24.9384
nostr-01.yakihonne.com,1.32123,103.695
relay.nostr.net,43.6532,-79.3832
nostr-relay.cbrx.io,43.6532,-79.3832
nrs-01.darkcloudarcade.com,39.0997,-94.5786
relay.trotters.cc:443,43.6532,-79.3832
strfry.bonsai.com,39.0438,-77.4874
relay.snort.social,53.3498,-6.26031
nostr.hekster.org,37.3986,-121.964
relay.mleku.dev,32.7767,-96.797
nostr-relay.xbytez.io:443,50.6924,3.20113
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
nostr.davenov.com,50.1109,8.68213
relay.chatbett.de,40.7128,-74.006
relayone.geektank.ai:443,39.0997,-94.5786
insta-relay.apps3.slidestr.net,40.4167,-3.70329
nostr.mom:443,50.4754,12.3683
nostr-relay.corb.net:443,39.6478,-104.988
relay.gulugulu.moe:443,43.6532,-79.3832
nostr.thebiglake.org,32.71,-96.6745
relay.nearhood.co.uk,51.5134,-0.0890675
relay1.nostrchat.io,60.1699,24.9384
rele.speyhard.fi,50.1109,8.68213
relay.bornheimer.app,50.1109,8.68213
nostr.fullstackcash.net,45.5201,-122.99
relay2.fiatdenier.com,50.1013,8.62643
relay.solife.me,43.6532,-79.3832
syb.lol,34.0549,-118.243
dev-relay.nostreon.com,60.1699,24.9384
relay.agorist.space:443,52.3734,4.89406
nostr.easycryptosend.it,43.6532,-79.3832
x.kojira.io,43.6532,-79.3832
adre.su,59.9311,30.3609
offchain.bostr.online,43.6532,-79.3832
relay.libernet.app,43.6532,-79.3832
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
nostr.rtvslawenia.com,49.4543,11.0746
relay.layer.systems,49.0291,8.35695
relay.arx-ccn.com,50.4754,12.3683
staging.yabu.me,35.6092,139.73
relay.vrtmrz.net,43.6532,-79.3832
nostr.bitcoiner.social,40.7608,-111.891
nostr.88mph.life,52.1941,-2.21905
relay.pprgb.app,52.52,13.405
relay.aarpia.com,37.3986,-121.964
nostrcity-club.fly.dev,38.7946,-106.535
buzz.ac2n-share.kozow.com,45.764,4.83566
relay.fckstate.net,59.3293,18.0686
nostr.thalheim.io:443,60.1699,24.9384
nostr.carroarmato0.be:443,50.914,3.21378
nostrcity-club.fly.dev:443,38.7946,-106.535
relay.routstr.com,59.4016,17.9455
nostr-dev.wellorder.net,45.5201,-122.99
relay.directsponsor.net,42.8864,-78.8784
relay.mwaters.net,50.9871,2.12554
relay.littlebitstudios.com,43.6532,-79.3832
relay.ohstr.com,43.6532,-79.3832
testnet-relay.samt.st,40.8302,-74.1299
relay.bullishbounty.com,43.6532,-79.3832
relay.nostrmap.net:443,60.1699,24.9384
relay.piazza.today,48.122,11.589

1 Relay URL Latitude Longitude
2 myvoiceourstory.org relay.damus.io 37.3598 43.6532 -121.981 -79.3832
3 nostr.linky.fit nostr2.girino.org 50.1109 43.6532 8.68213 -79.3832
4 public.obelisk.ar nostr.n7ekb.net 43.6532 47.4941 -79.3832 -122.294
5 relay.i9.eti.br rnostr.breadslice.com 43.6532 -79.3832
6 yabu.me portal-relay.pareto.space 35.6092 49.4543 139.73 11.0746
7 relay-us.zombi.cloudrodion.com relay.hasenpfeffr.com 40.7862 39.0438 -74.0743 -77.4874
8 relay.nostu.be nostr.blankfors.se 40.4167 60.1699 -3.70329 24.9384
9 relay.favillakey.io relay5.bitransfer.org -27.4705 43.6532 153.026 -79.3832
10 nostr.mikoshi.de santo.iguanatech.net 50.1109 40.8302 8.68213 -74.1299
11 relay.agilesolutionlabs.com nostr.ser1.net 43.6532 12.9716 -79.3832 77.5946
12 nostr.sovereignservices.xyz nostr.stakey.net 43.6532 52.3676 -79.3832 4.90414
13 relay.qstr.app relayrs.notoshi.win 50.1109 43.6532 8.68213 -79.3832
14 articles.layer3.news:443 orangepiller.org 37.3387 60.1699 -121.885 24.9384
15 relay.nostrops.com temp.iris.to 32.71 43.6532 -96.6745 -79.3832
16 relay.mrmave.work relay.cypherflow.ai 43.6532 48.8566 -79.3832 2.35222
17 nostr.islandarea.net:443 nostr.kalf.org 35.4669 52.3676 -97.6473 4.90414
18 nostr.azzamo.net:443 relay.unknown.cloud 52.2633 43.6532 21.0283 -79.3832
19 ec2.f7z.io relay.artx.market 60.1699 43.652 24.9384 -79.3633
20 relay.veganostr.com relay.arx-ccn.com 60.1699 50.4754 24.9384 12.3683
21 nostr.dlcdevkit.com relay.electriclifestyle.com 40.0992 26.2897 -83.1141 -80.1293
22 relay.44billion.net nostr-relay.online 43.6532 -79.3832
23 bendernostur.duckdns.org:8443 relay.jeffg.fyi 50.1109 43.6532 8.68213 -79.3832
24 nostras.app relay.wellorder.net 38.7946 45.5201 -106.535 -122.99
25 cs-relay.nostrdev.com:443 relay01.lnfi.network 50.4754 39.0997 12.3683 -94.5786
26 nostr.stakey.net noxir.kpherox.dev 52.3676 34.8587 4.90414 135.509
27 support.flotilla.social wot.sebastix.social 32.9483 51.8933 -96.7299 4.42083
28 relay.ordoplay.com nostr.overmind.lol 50.1109 43.6532 8.68213 -79.3832
29 nostr.stakey.net:443 nostr.red5d.dev 52.3676 43.6532 4.90414 -79.3832
30 relay.nostrmap.net nostr.zenon.network 60.1699 43.5009 24.9384 -70.4428
31 relayone.geektank.ai cyberspace.nostr1.com 39.0997 40.7128 -94.5786 -74.006
32 relay.novospes.com fenrir-s.notoshi.win 43.6532 -79.3832
33 relay.dyne.org no.str.cr 49.0291 9.92857 8.35705 -84.0528
34 nostrrelay.taylorperron.com relay.nostrdice.com 45.5029 -33.8688 -73.5723 151.209
35 nostrbtc.com nostr.data.haus 43.6532 50.4754 -79.3832 12.3683
36 relay.lanavault.space relay.g1sms.fr 60.1699 43.9432 24.9384 2.07537
37 relay.bullishbounty.com:443 relay.fundstr.me 43.6532 42.3601 -79.3832 -71.0589
38 relay.layer.systems:443 nostr.thaliyal.com 49.0291 40.8218 8.35695 -74.45
39 vault.iris.to:443 relay.mccormick.cx 43.6532 52.3563 -79.3832 4.95714
40 relay.getsafebox.app:443 nostr.makibisskey.work 43.6532 -79.3832
41 relay.wellorder.net shu01.shugur.net 45.5201 21.4902 -122.99 39.2246
42 relay.scuba323.com relay.angor.io 40.8218 48.1046 -74.45 11.6002
43 dm-test-strfry-discovery.samt.st nostr.primz.org 43.6532 -79.3832
44 temp.iris.to nostr-02.yakihonne.com 43.6532 1.32123 -79.3832 103.695
45 relay.paulstephenborile.com relay.freeplace.nl 49.4543 52.3676 11.0746 4.90414
46 relay.cyberguy.fyi relay.snort.social 52.6907 43.6532 4.8181 -79.3832
47 nostr.wecsats.io:443 nostr.rohoss.com 43.6532 50.1109 -79.3832 8.68213
48 bread.nostrsms.com relay.getsafebox.app 40.8218 43.6532 -74.45 -79.3832
49 relay.plebeian.market nostr.88mph.life 50.1109 43.6532 8.68213 -79.3832
50 relay.laantungir.net wot.sovbit.host -19.4692 64.1466 -42.5315 -21.9426
51 nostr.overmind.lol nostr-02.dorafactory.org 43.6532 1.35208 -79.3832 103.82
52 nip85.nosfabrica.com itanostr.space 39.0997 52.2931 -94.5786 4.79099
53 relay.conduit.market ribo.af.nostria.app 38.7946 -26.2041 -106.535 28.0473
54 relay.sigit.io nostr.azzamo.net 50.4754 52.2633 12.3683 21.0283
55 relay.dreamith.to relay.fountain.fm 43.6532 39.0997 -79.3832 -94.5786
56 relay.decentralia.fr relay.holzeis.me 48.122 43.6532 11.589 -79.3832
57 nexus.libernet.app relay.sincensura.org 43.6532 -79.3832
58 relay.nmail.li nostr-dev.wellorder.net 50.9871 45.5201 2.12554 -122.99
59 nostr-rs-relay.dev.fedibtc.com relay.hodl.ar 39.0438 -32.94 -77.4874 -60.6745
60 nostr-relay.nextblockvending.com nostr-relay.psfoundation.info 47.2343 39.0438 -119.853 -77.4874
61 nostr-relay.corb.net relay.goodmorningbitcoin.com 39.6478 43.6532 -104.988 -79.3832
62 relay.agentry.com nostr.rtvslawenia.com 42.8864 49.4543 -78.8784 11.0746
63 budabit.nostr1.com relay.13room.space 40.7057 43.6532 -74.0136 -79.3832
64 relay.cypherflow.ai nostr-02.czas.top 48.8575 53.471 2.35138 9.88208
65 purplerelay.com relay.chorus.community 43.6532 50.1109 -79.3832 8.68213
66 nostr.islandarea.net shu04.shugur.net 35.4669 25.2604 -97.6473 55.2989
67 relay.edufeed.org satsage.xyz 49.4521 37.3986 11.0767 -121.964
68 relay.bnos.space relay.bullishbounty.com 43.6532 -79.3832
69 relay.kizuna-miki.com nostr.sagaciousd.com 35.694 49.2827 139.754 -123.121
70 nostr.snowbla.de relay.nostromo.social 50.4754 49.4543 12.3683 11.0746
71 bitchat.nostr1.com relay.magiccity.live 40.7057 25.8128 -74.0136 -80.2377
72 relay.beamhop.com relay-dev.satlantis.io 43.6532 40.8302 -79.3832 -74.1299
73 relay.wavefunc.live nostr.coincrowd.fund 41.8781 39.0438 -87.6298 -77.4874
74 relayrs.notoshi.win shu05.shugur.net 43.6532 48.8566 -79.3832 2.35222
75 relay.nostrhub.fr nostrelay.memory-art.xyz 48.1045 43.6532 11.6004 -79.3832
76 relay01.lnfi.network nostr.now 35.6764 36.55 139.65 139.733
77 relay.bitos.space nostr-pub.wellorder.net 43.6532 45.5201 -79.3832 -122.99
78 relay.noeudlibre.fr nos.lol 50.6924 50.4754 3.20113 12.3683
79 relay.orly.dev wot.dergigi.com 32.7767 64.1476 -96.797 -21.9392
80 relay.paulstephenborile.com:443 inbox.azzamo.net 49.4543 52.2633 11.0746 21.0283
81 relay.atsocy.com nostr.mikoshi.de 43.6532 50.1109 -79.3832 8.68213
82 relay.keykeeper.world librerelay.aaroniumii.com 40.7824 43.6532 -74.0711 -79.3832
83 nostrmxn.lulus.com.mx relay.nosto.re 59.4016 51.8933 17.9455 4.42083
84 maxq.descendant.io relay.lumina.rocks 43.6532 49.0291 -79.3832 8.35695
85 nostr.spicyz.io:443 kitchen.zap.cooking 43.6532 -79.3832
86 nostr.chaima.info:443 zap.watch 50.1109 45.5029 8.68213 -73.5723
87 relay.opmaat.org nostr.spacecitynode.com 60.1699 29.7057 24.9384 -95.2706
88 nostr.notribe.net nostr.bilthon.dev 40.8302 25.8128 -74.1299 -80.2377
89 nostr-01.yakihonne.com:443 relay.aloftus.io 1.32123 34.0881 103.695 -118.379
90 offchain.pub shu02.shugur.net 39.1585 21.4902 -94.5728 39.2246
91 relay.agora.social relay.mwaters.net 50.7383 50.9871 15.0648 2.12554
92 relay.tdw.lol relay.moinsen.com 41.8781 50.4754 -87.6298 12.3683
93 nostr.nodesmap.com nostr.kungfu-g.rip 59.3327 33.7946 18.0656 -84.4488
94 relay.getvia.xyz nostr.sathoarder.com 60.1699 48.5734 24.9384 7.75211
95 public.crostr.com nostream.breadslice.com 43.6532 -79.3832
96 relay.agorist.space slick.mjex.me 52.3734 39.048 4.89406 -77.4817
97 relay.lacrypta.ar nostr.jerrynya.fun 43.6532 31.2304 -79.3832 121.474
98 nostr.spaceshell.xyz articles.layer3.news 43.6532 37.3387 -79.3832 -121.885
99 relay.nuts.cash nostr.satstralia.com 52.3676 64.1476 4.90414 -21.9392
100 relay.otrta.me relay.degmods.com 50.1109 50.4754 8.68213 12.3683
101 relay.satmaxt.xyz relay.0xchat.com 43.6532 1.35208 -79.3832 103.82
102 relay.earthly.city relay.nostx.io 34.1749 43.6532 -118.54 -79.3832
103 relay.wisp.talk:443 relay.siamdev.cc 49.4543 13.9178 11.0746 100.424
104 mostro-p2p.tech relay.mattybs.lol 50.1109 43.6532 8.68213 -79.3832
105 nostr.thalheim.io relay.bitcoinveneto.org 60.1699 64.1466 24.9384 -21.9426
106 relay.s-w.art nostr.tadryanom.me 43.6532 -79.3832
107 nostr.chaima.info nostr.spaceshell.xyz 50.1109 43.6532 8.68213 -79.3832
108 articles.layer3.news relay.nostr.band 37.3387 60.1699 -121.885 24.9384
109 nostr2.girino.org:443 relay03.lnfi.network 43.6532 39.0997 -79.3832 -94.5786
110 relay.mostr.pub:443 schnorr.me 43.6532 -79.3832
111 relay.ditto.pub:443 khatru.nostrver.se 43.6532 51.8933 -79.3832 4.42083
112 relay.manneken.brussels dealtime-footwear-angry-records.trycloudflare.com 49.4543 43.6532 11.0746 -79.3832
113 relay.kaleidoswap.com relay.endfiat.money 50.8476 43.6532 4.35717 -79.3832
114 nostrelay.circum.space internationalright-wing.org 52.2245 -22.5022 8.826 -48.7114
115 relay.beginningend.com nostr.mom 35.2227 50.4754 -97.4786 12.3683
116 relay-dev.gulugulu.moe nostrue.com 43.6532 40.8054 -79.3832 -74.0241
117 relay.fundstr.me relay.chakany.systems 42.3601 43.6532 -71.0589 -79.3832
118 relay.jmoose.rocks:443 vidono.apps.slidestr.net 37.4419 48.8566 -122.143 2.35222
119 relay.nostr.dev.br relay.stream.labs.h3.se 48.1046 59.4016 11.6002 17.9455
120 relay.minibolt.info gnostr.com 43.6532 40.9017 -79.3832 29.1616
121 nostr.unkn0wn.world relay.origin.land 46.8499 35.6673 9.53287 139.751
122 vault.iris.to relay.sigit.io 43.6532 50.4754 -79.3832 12.3683
123 relay.guggero.org theoutpost.life 46.5971 64.1476 9.59652 -21.9392
124 relay.getsafebox.app adre.su 43.6532 59.9311 -79.3832 30.3609
125 relay.bitmacro.cloud soloco.nl 43.6532 -79.3832
126 relay.wavlake.com wot.soundhsa.com 41.2619 34.0479 -95.8608 -118.256
127 nostr.hekster.org:443 nos.xmark.cc 37.3986 50.6924 -121.964 3.20113
128 relay.lightning.pub nostr-relay.zimage.com 39.0438 34.282 -77.4874 -118.439
129 relay-dev.gulugulu.moe:443 nostr.pleb.one 43.6532 38.6327 -79.3832 -90.1961
130 soloco.nl purpura.cloud 43.6532 -79.3832
131 nostr.hifish.org ribo.us.nostria.app 47.4009 41.5868 8.57814 -93.625
132 relay-can.zombi.cloudrodion.com relay.digitalezukunft.cyou 43.6532 45.5019 -79.3832 -73.5674
133 dm-test-strfry-generic.samt.st relay.mess.ch 43.6532 47.3591 -79.3832 8.55292
134 relay.wavefunc.live:443 relay.javi.space 41.8781 43.4633 -87.6298 11.8796
135 porchlight.social nostr.jfischer.org 43.6532 49.0291 -79.3832 8.35696
136 relay.zone667.com nostr.rblb.it 60.1699 43.4633 24.9384 11.8796
137 nostr.data.haus:443 relay.nostriot.com 50.4754 41.5695 12.3683 -83.9786
138 relay.nexterz.com nostr.21crypto.ch 43.6532 47.4988 -79.3832 8.72369
139 relay.sigit.io:443 relay.nostr.vet 50.4754 52.6467 12.3683 4.7395
140 nostr.girino.org fanfares.nostr1.com 43.6532 40.7057 -79.3832 -74.0136
141 nostr.liberty.fans relay.copylaradio.com 36.8767 51.223 -89.5879 6.78245
142 wheat.happytavern.co relay.mostro.network 43.6532 40.8302 -79.3832 -74.1299
143 relay.0xchat.com nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
144 inbox.scuba323.com relay.lifpay.me 40.8218 1.35208 -74.45 103.82
145 nostr.vulpem.com nostr.tac.lol 49.4543 47.4748 11.0746 -122.273
146 relay.damustr.com relay.nostraddress.com 43.6532 -79.3832
147 no.str.cr:443 wot.sudocarlos.com 8.96171 51.5072 -83.5246 -0.127586
148 relay.vrtmrz.net:443 nostr.spicyz.io 43.6532 -79.3832
149 vm-1734.lnvps.cloud relay02.lnfi.network 53.3498 39.0997 -6.26031 -94.5786
150 testnet.samt.st nostr.4rs.nl 43.6532 49.0291 -79.3832 8.35696
151 nostr.rtvslawenia.com:443 relay.21e6.cz 49.4543 50.1682 11.0746 14.0546
152 nostr.sathoarder.com:443 nostr.casa21.space 48.5734 43.6532 7.75211 -79.3832
153 relay.cosmicbolt.net wot.codingarena.top 37.3986 50.4754 -121.964 12.3683
154 relay.staging.plebeian.market nostr-01.yakihonne.com 51.5072 1.32123 -0.127586 103.695
155 relayone.soundhsa.com strfry.openhoofd.nl 39.0997 51.9229 -94.5786 4.40833
156 relay.olas.app:443 nostr.plantroon.com 60.1699 50.1013 24.9384 8.62643
157 relay.illuminodes.com dev-nostr.bityacht.io 43.6532 25.0797 -79.3832 121.234
158 relay.chorus.community:443 ribo.eu.nostria.app 48.5333 52.3676 10.7 4.90414
159 relay.ohstr.com:443 wot.nostr.place 43.6532 30.2672 -79.3832 -97.7431
160 fanfares.nostr1.com:443 relay.dwadziesciajeden.pl 40.7057 52.2297 -74.0136 21.0122
161 nostr.planix.org prl.plus 43.6532 55.7623 -79.3832 37.6381
162 schnorr.me relay.bitcoinartclock.com 43.6532 50.4754 -79.3832 12.3683
163 relay.mostro.network nostr.2b9t.xyz 40.8302 34.0549 -74.1299 -118.243
164 nostr-verified.wellorder.net a.nos.lol 45.5201 50.4754 -122.99 12.3683
165 ynostr.yael.at relay.wavlake.com 60.1699 41.2619 24.9384 -95.8608
166 nostr.wild-vibes.ts.net relay.toastr.net 48.8566 40.8054 2.35222 -74.0241
167 relay.mypathtofire.de wheat.happytavern.co 42.8864 43.6532 -78.8784 -79.3832
168 relay.klabo.world relay.lnfi.network 47.674 39.0438 -122.122 -77.4874
169 nostr.tagomago.me nostr.liberty.fans 42.3601 36.9104 -71.0589 -89.5875
170 relay.veganostr.com:443 vitor.nostr1.com 60.1699 40.7057 24.9384 -74.0136
171 nrs-01.darkcloudarcade.com:443 nostr.thebiglake.org 39.0997 32.71 -94.5786 -96.6745
172 relay.loveisbitcoin.com relay.exit.pub 43.6532 50.4754 -79.3832 12.3683
173 bridge.tagomago.me freelay.sovbit.host 42.3601 64.1476 -71.0589 -21.9392
174 relay-rpi.edufeed.org:443 purplerelay.com 49.4521 50.1109 11.0767 8.68213
175 relay.kilombino.com nostr.camalolo.com 43.6532 24.1469 -79.3832 120.684
176 relay.sharegap.net black.nostrcity.club 43.6532 41.8781 -79.3832 -87.6298
177 nostr.yutakobayashi.com relay.barine.co 43.6532 -79.3832
178 relay-na1.metanomalist.com nostr.0x7e.xyz 43.6532 47.4988 -79.3832 8.72369
179 public.crostr.com:443 relayone.soundhsa.com 43.6532 34.0479 -79.3832 -118.256
180 nostrride.io relay.vrtmrz.net 37.3986 43.6532 -121.964 -79.3832
181 nostr.bgbitcoin.club:443 nostr-03.dorafactory.org 50.4754 1.35208 12.3683 103.82
182 nostr.christiansass.de r.bitcoinhold.net 50.1109 43.6532 8.68213 -79.3832
183 nos.lol:443 relay.agorist.space 50.4754 52.3734 12.3683 4.89406
184 relay.pyramid.li nostr-relay.amethyst.name 47.4093 39.0067 8.46503 -77.4291
185 relay.mappingbitcoin.com relay-rpi.edufeed.org 43.6532 49.4543 -79.3832 11.0746
186 nostr-relay.zimage.com orangesync.tech 34.282 50.1109 -118.439 8.68213
187 offchain.pub:443 relay04.lnfi.network 39.1585 39.0997 -94.5728 -94.5786
188 nostr.myshosholoza.co.za:443 nostr.hifish.org 52.3676 47.4043 4.90414 8.57398
189 0x-nostr-relay.fly.dev relay.nostr.wirednet.jp 38.7946 34.706 -106.535 135.493
190 relay1.privkey.io nostr.girino.org 25.7617 43.6532 -80.1918 -79.3832
191 chat-relay.zap-work.com:443 strfry.felixzieger.de 43.6532 50.1013 -79.3832 8.62643
192 relay.staging.plebeian.market:443 nostr.night7.space 51.5072 50.4754 -0.127586 12.3683
193 nostr.robosats.org nostr.oxtr.dev 64.1476 50.4754 -21.9392 12.3683
194 relay.endfiat.money:443 relay.nostrhub.fr 59.3327 48.1046 18.0656 11.6002
195 nostr.agh.ccwu.cc nostr.rikmeijer.nl 43.6532 50.4754 -79.3832 12.3683
196 nostr.overmind.lol:443 wot.basspistol.org 43.6532 49.4521 -79.3832 11.0767
197 nostr.tac.lol nostr.coincards.com 47.4748 53.5501 -122.273 -113.469
198 relay.ru.ac.th solo.itsalldance.space 13.7607 32.71 100.627 -96.6745
199 nostr-relay.xbytez.io relay.puresignal.news 50.6924 43.6532 3.20113 -79.3832
200 relay.islandbitcoin.com nostrelay.circum.space 12.8498 51.2217 77.6545 6.77616
201 relay5.bitransfer.org relay.btcforplebs.com 43.6532 -79.3832
202 prl.plus nostr.diakod.com 55.7628 43.6532 37.5983 -79.3832
203 chat-relay.zap-work.com tollbooth.stens.dev 43.6532 51.223 -79.3832 6.78245
204 relay.satlantis.io relay.usefusion.ai 39.0438 -77.4874
205 nostr.bitcoiner.social:443 relay-admin.thaliyal.com 40.7608 40.8218 -111.891 -74.45
206 nostr.ac nproxy.kristapsk.lv 38.958 60.1699 -77.3592 24.9384
207 relay.solomonstr.com nostr-verified.wellorder.net 43.6532 45.5201 -79.3832 -122.99
208 strfry.apps3.slidestr.net wot.brightbolt.net 40.4167 47.6735 -3.70329 -116.781
209 relay.nostr.com relay.orangepill.ovh 50.1109 49.1689 8.68213 -0.358841
210 reraw.pbla2fish.cc relay.olas.app 43.6532 50.4754 -79.3832 12.3683
211 nostr.bond offchain.pub 50.1109 36.1809 8.68213 -115.241
212 relay.wavlake.com:443 nostr.l484.com 41.2619 30.2944 -95.8608 -97.6223
213 nos.lol relay.nostrhub.tech 50.4754 49.4543 12.3683 11.0746
214 nostr.debate.report relay.tapestry.ninja 50.1109 40.8054 8.68213 -74.0241
215 relay-testnet.k8s.layer3.news nostr.snowbla.de 37.3387 60.1699 -121.885 24.9384
216 relay.lanacoin-eternity.com:443 relay.letsfo.com 40.8302 51.098 -74.1299 17.0321
217 relay.wisp.talk relay.tagayasu.xyz 49.4543 43.6715 11.0746 -79.38
218 nostr.tabordalab.com strfry.bonsai.com 60.1699 37.8715 24.9384 -122.273
219 relay.trotters.cc nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
220 nostr.4rs.nl nostr.smut.cloud 49.0291 43.6532 8.35696 -79.3832
221 relay.hivetalk.org x.kojira.io 40.8302 43.6532 -74.1299 -79.3832
222 spamspamspamspam.rest yabu.me 43.6532 35.6092 -79.3832 139.73
223 nostr.janx.com relay.zone667.com 43.6532 60.1699 -79.3832 24.9384
224 relay.mostr.pub relay.bitcoindistrict.org 43.6532 -79.3832
225 relay.lightning.pub:443 pyramid.fiatjaf.com 39.0438 50.1109 -77.4874 8.68213
226 relay.samt.st nostr.hekster.org 40.8302 37.3986 -74.1299 -121.964
227 bruh.samt.st relay.laantungir.net 43.6532 -19.4692 -79.3832 -42.5315
228 relay.orangesync.tech relay.cosmicbolt.net 40.7128 37.3986 -74.006 -121.964
229 relay-fra.zombi.cloudrodion.com strfry.shock.network 48.8566 41.8959 2.35222 -88.2169
230 relay.notoshi.win relay.coinos.io 13.3396 43.6532 100.93 -79.3832
231 relay.nostreon.com wot.nostr.net 60.1699 43.6532 24.9384 -79.3832
232 nostr.purpura.cloud nostr.einundzwanzig.space 43.6532 50.1109 -79.3832 8.68213
233 basspistol.org premium.primal.net 49.0291 43.6532 8.35696 -79.3832
234 relay.nostrfeed.com dev-relay.lnfi.network 60.1699 39.0997 24.9384 -94.5786
235 nostr.dlcdevkit.com:443 ynostr.yael.at 40.0992 60.1699 -83.1141 24.9384
236 nostr.21crypto.ch relay.fr13nd5.com 47.5356 52.5233 8.73209 13.3426
237 nostr.pbfs.io nostr.lojong.info 50.4754 43.6532 12.3683 -79.3832
238 social.amanah.eblessing.co srtrelay.c-stellar.net 48.1046 43.6532 11.6002 -79.3832
239 relay.cosmicbolt.net:443 nostr.myshosholoza.co.za 37.3986 52.3676 -121.964 4.90414
240 21milionidinostr.duckdns.org alien.macneilmediagroup.com 41.8967 43.6532 12.4822 -79.3832
241 fanfares.nostr1.com relay.utxo.farm 40.7057 35.6916 -74.0136 139.768
242 nostr.na.social relay.ru.ac.th 43.6532 13.7584 -79.3832 100.622
243 nostr.data.haus relay2.angor.io 50.4754 48.1046 12.3683 11.6002
244 strfry.shock.network:443 relay.seq1.net 39.0438 43.6532 -77.4874 -79.3832
245 nostr.pbfs.io:443 relay.illuminodes.com 50.4754 47.6061 12.3683 -122.333
246 relay.shadowbip.com relay.satlantis.io 50.1109 32.8769 8.68213 -80.0114
247 relay.ltgnet.work:8443 relay.primal.net 51.0511 43.6532 -114.075 -79.3832
248 relay02.lnfi.network nostr.vulpem.com 35.6764 49.4543 139.65 11.0746
249 nostr.myshosholoza.co.za relay.evanverma.com 52.3676 40.8302 4.90414 -74.1299
250 relay.satsmarkt.club nostr.chaima.info 52.6907 51.223 4.8181 6.78245
251 nostr-rs-relay.dev.fedibtc.com:443 relay.notoshi.win 39.0438 13.7829 -77.4874 100.546
252 auth.nostr1.com nostr.carroarmato0.be 40.7057 50.9928 -74.0136 3.26317
253 dm-test-nostr-rs-42-disabled.samt.st relay.ditto.pub 43.6532 -79.3832
254 bitcoinostr.duckdns.org relay.credenso.cafe 38.9504 43.1149 -0.14007 -80.7228
255 relayrs.notoshi.win:443 relay.nostr.place 43.6532 32.7767 -79.3832 -96.797
256 nostr.whitenode45.ddns.net relay.conduit.market 40.55 43.6532 -74.4758 -79.3832
257 relay.mccormick.cx:443 nostr.huszonegy.world 52.3563 47.4979 4.95714 19.0402
258 nostr.2b9t.xyz:443 nostr-rs-relay.dev.fedibtc.com 34.0549 39.0438 -118.243 -77.4874
259 relay.internationalright-wing.org wot.nostr.party -22.4692 36.1627 -48.9875 -86.7816
260 nostr.2b9t.xyz wot.dtonon.com 34.0549 43.6532 -118.243 -79.3832
261 relay.mitchelltribe.com nostr.middling.mydns.jp 39.0438 35.8099 -77.4874 140.12
262 relay.nostriot.com nostr-2.21crypto.ch 41.5695 47.4988 -83.9786 8.72369
263 infinity-signal-relay.digitalforlifeagency.workers.dev nostr.dlsouza.lol 43.6532 50.1109 -79.3832 8.68213
relay.sector01.com 41.4513 -81.7021
relay.nostx.io 43.6532 -79.3832
relay.fiatdenier.com 43.7787 -79.5393
relayone.soundhsa.com:443 39.0997 -94.5786
strfry.bonsai.com:443 39.0438 -77.4874
nostr.snowbla.de:443 50.4754 12.3683
relay.btcforplebs.com 43.6532 -79.3832
dev.relay.stream 43.6532 -79.3832
nostr-pub.wellorder.net 45.5201 -122.99
nostr.twinkle.lol 51.902 7.6657
nostr.carroarmato0.be 50.914 3.21378
bcast.girino.org 43.6532 -79.3832
treuzkas.branruz.com 48.8575 2.35138
dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
relay.endfiat.money 59.3327 18.0656
relay.jmoose.rocks 37.4419 -122.143
relay.degmods.com 50.4754 12.3683
relay.ditto.pub 43.6532 -79.3832
nostrelay.circum.space:443 52.2245 8.826
relay.pocketnostr.com 40.8054 -74.0241
nostr.wecsats.io 43.6532 -79.3832
node.kommonzenze.de 49.4521 11.0767
nostr.sathoarder.com 48.5734 7.75211
chorus.mikedilger.com:444 -36.8906 174.794
cache.trustr.ing 43.6548 -79.3885
bitcoiner.social 40.7608 -111.891
relay.tdw.gg 34.0549 -118.243
relay.edufeed.org:443 49.4521 11.0767
relay.snotr.nl:49999 51.9758 4.31389
nostr.bgbitcoin.club 50.4754 12.3683
relay.bowlafterbowl.com 32.9483 -96.7299
relay-rpi.edufeed.org 49.4521 11.0767
x.kojira.io:443 43.6532 -79.3832
nostr.self-determined.de 51.4379 14.2355
schnorr.me:443 43.6532 -79.3832
nostr.computingcache.com 45.5341 -122.956
cs-relay.nostrdev.com 50.4754 12.3683
relay2.veganostr.com 60.1699 24.9384
relay.phostrich.com 40.55 -74.4758
relay.nostrian-conquest.com 41.223 -111.974
relay.flashapp.me 43.6548 -79.3885
relay.yoinekodo.jp 43.6532 -79.3832
relay-arg.zombi.cloudrodion.com 1.35208 103.82
freelay.sovbit.host 60.1699 24.9384
nostr.n7ekb.net 47.4941 -122.294
purplerelay.com:443 43.6532 -79.3832
nostr.tbxnetworx.de 47.7228 9.96456
relay.bnos.space:443 43.6532 -79.3832
relay.satsapp.me 50.4754 12.3683
nostr-01.uid.ovh 50.9871 2.12554
tribune-panel-growing-noon.trycloudflare.com 43.6532 -79.3832
nostr.mom 50.4754 12.3683
no.str.cr 8.96171 -83.5246
nostr.spicyz.io 43.6532 -79.3832
memlay.v0l.io 53.3498 -6.26031
bucket.coracle.social 37.7775 -122.397
nostr.azzamo.net 52.2633 21.0283
relay.lanacoin-eternity.com 40.8302 -74.1299
nostr-kyomu-haskell.onrender.com 37.7775 -122.397
conduitl2.fly.dev 38.7946 -106.535
relay.wasabiwallet.io 43.6532 -79.3832
portal-relay.pareto.space 49.0291 8.35696
strfry.ymir.cloud 43.6532 -79.3832
nostr.hoppe-relay.it.com 42.8864 -78.8784
nostr.red5d.dev 43.6532 -79.3832
strfry.shock.network 39.0438 -77.4874
relay.chorus.community 48.5333 10.7
relay.mitchelltribe.com:443 39.0438 -77.4874
relay.olas.app 60.1699 24.9384
testnet-relay.samt.st:443 40.8302 -74.1299
relay.dwadziesciajeden.pl 52.2297 21.0122
relay.gulugulu.moe 43.6532 -79.3832
relay.underorion.se 50.1109 8.68213
relay.mccormick.cx 52.3563 4.95714
top.testrelay.top 43.6532 -79.3832
nostr-02.uid.ovh 50.9871 2.12554
nostr.relay.hedwig.sh 60.1699 24.9384
nostr-01.yakihonne.com 1.32123 103.695
relay.nostr.net 43.6532 -79.3832
nostr-relay.cbrx.io 43.6532 -79.3832
nrs-01.darkcloudarcade.com 39.0997 -94.5786
relay.trotters.cc:443 43.6532 -79.3832
strfry.bonsai.com 39.0438 -77.4874
relay.snort.social 53.3498 -6.26031
nostr.hekster.org 37.3986 -121.964
relay.mleku.dev 32.7767 -96.797
nostr-relay.xbytez.io:443 50.6924 3.20113
nosflare-leefcore.leefcore.workers.dev 43.6532 -79.3832
nostr.davenov.com 50.1109 8.68213
relay.chatbett.de 40.7128 -74.006
relayone.geektank.ai:443 39.0997 -94.5786
insta-relay.apps3.slidestr.net 40.4167 -3.70329
nostr.mom:443 50.4754 12.3683
nostr-relay.corb.net:443 39.6478 -104.988
relay.gulugulu.moe:443 43.6532 -79.3832
nostr.thebiglake.org 32.71 -96.6745
relay.nearhood.co.uk 51.5134 -0.0890675
relay1.nostrchat.io 60.1699 24.9384
rele.speyhard.fi 50.1109 8.68213
relay.bornheimer.app 50.1109 8.68213
nostr.fullstackcash.net 45.5201 -122.99
relay2.fiatdenier.com 50.1013 8.62643
relay.solife.me 43.6532 -79.3832
syb.lol 34.0549 -118.243
dev-relay.nostreon.com 60.1699 24.9384
relay.agorist.space:443 52.3734 4.89406
nostr.easycryptosend.it 43.6532 -79.3832
x.kojira.io 43.6532 -79.3832
adre.su 59.9311 30.3609
offchain.bostr.online 43.6532 -79.3832
relay.libernet.app 43.6532 -79.3832
nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
nostr.rtvslawenia.com 49.4543 11.0746
relay.layer.systems 49.0291 8.35695
relay.arx-ccn.com 50.4754 12.3683
staging.yabu.me 35.6092 139.73
relay.vrtmrz.net 43.6532 -79.3832
nostr.bitcoiner.social 40.7608 -111.891
nostr.88mph.life 52.1941 -2.21905
relay.pprgb.app 52.52 13.405
relay.aarpia.com 37.3986 -121.964
nostrcity-club.fly.dev 38.7946 -106.535
buzz.ac2n-share.kozow.com 45.764 4.83566
relay.fckstate.net 59.3293 18.0686
nostr.thalheim.io:443 60.1699 24.9384
nostr.carroarmato0.be:443 50.914 3.21378
nostrcity-club.fly.dev:443 38.7946 -106.535
relay.routstr.com 59.4016 17.9455
nostr-dev.wellorder.net 45.5201 -122.99
relay.directsponsor.net 42.8864 -78.8784
relay.mwaters.net 50.9871 2.12554
relay.littlebitstudios.com 43.6532 -79.3832
relay.ohstr.com 43.6532 -79.3832
testnet-relay.samt.st 40.8302 -74.1299
relay.bullishbounty.com 43.6532 -79.3832
relay.nostrmap.net:443 60.1699 24.9384
relay.piazza.today 48.122 11.589

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

@ -3,43 +3,27 @@ package com.bitchat.android
import android.app.Application import android.app.Application
import com.bitchat.android.nostr.RelayDirectory import com.bitchat.android.nostr.RelayDirectory
import com.bitchat.android.ui.theme.ThemePreferenceManager import com.bitchat.android.ui.theme.ThemePreferenceManager
import com.bitchat.android.net.ArtiTorManager import com.bitchat.android.net.TorManager
/** /**
* Main application class for bitchat Android * Main application class for bitchat Android
*/ */
class BitchatApplication : Application() { class BitchatApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.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 // Initialize Tor first so any early network goes over Tor
try { try { TorManager.init(this) } catch (_: Exception) { }
val torProvider = ArtiTorManager.getInstance()
torProvider.init(this)
} catch (_: Exception){}
// Initialize relay directory (loads assets/nostr_relays.csv) // Initialize relay directory (loads assets/nostr_relays.csv)
RelayDirectory.initialize(this) RelayDirectory.initialize(this)
// Initialize LocationNotesManager dependencies early so sheet subscriptions can start immediately
try { com.bitchat.android.nostr.LocationNotesInitializer.initialize(this) } catch (_: Exception) { }
// Initialize favorites persistence early so MessageRouter/NostrTransport can use it on startup // Initialize favorites persistence early so MessageRouter/NostrTransport can use it on startup
try { try {
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this) com.bitchat.android.favorites.FavoritesPersistenceService.initialize(this)
} catch (_: Exception) { } } 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 // Warm up Nostr identity to ensure npub is available for favorite notifications
try { try {
com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this) com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this)
@ -48,34 +32,9 @@ class BitchatApplication : Application() {
// Initialize theme preference // Initialize theme preference
ThemePreferenceManager.init(this) 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) // Initialize debug preference manager (persists debug toggles)
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { } try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }
// Initialize WiFi Aware controller with persisted default
try {
val enabled = com.bitchat.android.ui.debug.DebugPreferenceManager.getWifiAwareEnabled(false)
com.bitchat.android.wifiaware.WifiAwareController.initialize(this, enabled)
} catch (_: Exception) { }
// Initialize Geohash Registries for persistence
try {
com.bitchat.android.nostr.GeohashAliasRegistry.initialize(this)
com.bitchat.android.nostr.GeohashConversationRegistry.initialize(this)
} catch (_: Exception) { }
// Own relay connectivity, selected-channel subscriptions, and presence scheduling at the
// process level so closing the Activity does not disconnect Nostr.
try { com.bitchat.android.nostr.NostrBackgroundRuntime.initialize(this) } catch (_: Exception) { }
// Initialize mesh service preferences
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
// Proactively start the foreground service to keep mesh alive
try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { }
// TorManager already initialized above // TorManager already initialized above
} }
} }

View File

@ -1,9 +1,9 @@
package com.bitchat.android package com.bitchat.android
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@ -15,21 +15,18 @@ import androidx.compose.material3.Scaffold
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import com.bitchat.android.mesh.BluetoothMeshService 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.BluetoothCheckScreen
import com.bitchat.android.onboarding.BluetoothStatus import com.bitchat.android.onboarding.BluetoothStatus
import com.bitchat.android.onboarding.BluetoothStatusManager import com.bitchat.android.onboarding.BluetoothStatusManager
import com.bitchat.android.onboarding.BatteryOptimizationManager import com.bitchat.android.onboarding.BatteryOptimizationManager
import com.bitchat.android.onboarding.BatteryOptimizationPreferenceManager
import com.bitchat.android.onboarding.BatteryOptimizationScreen import com.bitchat.android.onboarding.BatteryOptimizationScreen
import com.bitchat.android.onboarding.BatteryOptimizationStatus import com.bitchat.android.onboarding.BatteryOptimizationStatus
import com.bitchat.android.onboarding.BackgroundLocationPermissionScreen
import com.bitchat.android.onboarding.InitializationErrorScreen import com.bitchat.android.onboarding.InitializationErrorScreen
import com.bitchat.android.onboarding.InitializingScreen import com.bitchat.android.onboarding.InitializingScreen
import com.bitchat.android.onboarding.LocationCheckScreen import com.bitchat.android.onboarding.LocationCheckScreen
@ -41,91 +38,41 @@ import com.bitchat.android.onboarding.PermissionExplanationScreen
import com.bitchat.android.onboarding.PermissionManager import com.bitchat.android.onboarding.PermissionManager
import com.bitchat.android.ui.ChatScreen import com.bitchat.android.ui.ChatScreen
import com.bitchat.android.ui.ChatViewModel import com.bitchat.android.ui.ChatViewModel
import com.bitchat.android.ui.OrientationAwareActivity
import com.bitchat.android.ui.theme.BitchatTheme import com.bitchat.android.ui.theme.BitchatTheme
import com.bitchat.android.wifiaware.WifiAwareController
import com.bitchat.android.nostr.PoWPreferenceManager import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.services.VerificationService
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity : OrientationAwareActivity() { class MainActivity : ComponentActivity() {
private lateinit var permissionManager: PermissionManager private lateinit var permissionManager: PermissionManager
private lateinit var onboardingCoordinator: OnboardingCoordinator private lateinit var onboardingCoordinator: OnboardingCoordinator
private lateinit var bluetoothStatusManager: BluetoothStatusManager private lateinit var bluetoothStatusManager: BluetoothStatusManager
private lateinit var locationStatusManager: LocationStatusManager private lateinit var locationStatusManager: LocationStatusManager
private lateinit var batteryOptimizationManager: BatteryOptimizationManager private lateinit var batteryOptimizationManager: BatteryOptimizationManager
// Core mesh service - provided by the foreground service holder // Core mesh service - managed at app level
private lateinit var meshService: BluetoothMeshService private lateinit var meshService: BluetoothMeshService
private lateinit var unifiedMeshService: MeshService
private val mainViewModel: MainViewModel by viewModels() private val mainViewModel: MainViewModel by viewModels()
private var pendingMeshForegroundServiceStart = false
private val chatViewModel: ChatViewModel by viewModels { private val chatViewModel: ChatViewModel by viewModels {
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T { override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return ChatViewModel(application, meshService, unifiedMeshService) as T return ChatViewModel(application, meshService) as T
} }
} }
} }
private val forceFinishReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: android.content.Context, intent: android.content.Intent) {
if (intent.action == com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH) {
android.util.Log.i("MainActivity", "Received force finish broadcast, closing UI")
finishAffinity()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
this.setRecentsScreenshotEnabled(false)
}
// Register receiver for force finish signal from shutdown coordinator
val filter = android.content.IntentFilter(com.bitchat.android.util.AppConstants.UI.ACTION_FORCE_FINISH)
if (android.os.Build.VERSION.SDK_INT >= 33) {
registerReceiver(
forceFinishReceiver,
filter,
com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH,
null,
android.content.Context.RECEIVER_NOT_EXPORTED
)
} else {
@Suppress("DEPRECATION")
registerReceiver(
forceFinishReceiver,
filter,
com.bitchat.android.util.AppConstants.UI.PERMISSION_FORCE_FINISH,
null
)
}
// Check if this is a quit request from the notification
if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
android.util.Log.d("MainActivity", "Quit request received in onCreate, finishing activity")
finish()
return
}
com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown()
// Enable edge-to-edge display for modern Android look // Enable edge-to-edge display for modern Android look
enableEdgeToEdge() enableEdgeToEdge()
// Initialize permission management // Initialize permission management
permissionManager = PermissionManager(this) permissionManager = PermissionManager(this)
// Start the foreground service when allowed, then get mesh instances from the holder. // Initialize core mesh service first
startMeshForegroundServiceBestEffort() meshService = BluetoothMeshService(this)
meshService = com.bitchat.android.service.MeshServiceHolder.getOrCreate(applicationContext)
unifiedMeshService = com.bitchat.android.service.MeshServiceHolder.getUnifiedOrCreate(applicationContext)
// Expose BLE mesh to WiFi Aware controller for cross-transport relays - DEPRECATED
// Bridging is now handled by TransportBridgeService automatically
bluetoothStatusManager = BluetoothStatusManager( bluetoothStatusManager = BluetoothStatusManager(
activity = this, activity = this,
context = this, context = this,
@ -148,9 +95,6 @@ class MainActivity : OrientationAwareActivity() {
activity = this, activity = this,
permissionManager = permissionManager, permissionManager = permissionManager,
onOnboardingComplete = ::handleOnboardingComplete, onOnboardingComplete = ::handleOnboardingComplete,
onBackgroundLocationRequired = {
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
},
onOnboardingFailed = ::handleOnboardingFailed onOnboardingFailed = ::handleOnboardingFailed
) )
@ -176,17 +120,6 @@ class MainActivity : OrientationAwareActivity() {
} }
} }
} }
// Keep the unified mesh delegate attached when Wi-Fi Aware starts after the UI.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
WifiAwareController.running.collect { running ->
if (running && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
unifiedMeshService.delegate = chatViewModel
}
}
}
}
// Only start onboarding process if we're in the initial CHECKING state // Only start onboarding process if we're in the initial CHECKING state
// This prevents restarting onboarding on configuration changes // This prevents restarting onboarding on configuration changes
@ -222,6 +155,7 @@ class MainActivity : OrientationAwareActivity() {
onDispose { onDispose {
try { try {
context.unregisterReceiver(receiver) context.unregisterReceiver(receiver)
Log.d("BluetoothStatusUI", "BroadcastReceiver unregistered")
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
Log.w("BluetoothStatusUI", "Receiver was not registered") Log.w("BluetoothStatusUI", "Receiver was not registered")
} }
@ -229,7 +163,7 @@ class MainActivity : OrientationAwareActivity() {
} }
when (onboardingState) { when (onboardingState) {
OnboardingState.PERMISSION_REQUESTING -> { OnboardingState.CHECKING -> {
InitializingScreen(modifier) InitializingScreen(modifier)
} }
@ -244,10 +178,6 @@ class MainActivity : OrientationAwareActivity() {
onRetry = { onRetry = {
checkBluetoothAndProceed() checkBluetoothAndProceed()
}, },
onSkip = {
mainViewModel.skipBluetoothCheck()
checkLocationAndProceed()
},
isLoading = isBluetoothLoading isLoading = isBluetoothLoading
) )
} }
@ -296,23 +226,16 @@ class MainActivity : OrientationAwareActivity() {
} }
) )
} }
OnboardingState.BACKGROUND_LOCATION_EXPLANATION -> { OnboardingState.PERMISSION_REQUESTING -> {
BackgroundLocationPermissionScreen( InitializingScreen(modifier)
modifier = modifier,
onContinue = {
onboardingCoordinator.requestBackgroundLocation()
},
onRetry = {
onboardingCoordinator.checkBackgroundLocationAndProceed()
},
onSkip = {
onboardingCoordinator.skipBackgroundLocation()
}
)
} }
OnboardingState.CHECKING, OnboardingState.INITIALIZING, OnboardingState.COMPLETE -> { OnboardingState.INITIALIZING -> {
InitializingScreen(modifier)
}
OnboardingState.COMPLETE -> {
// Set up back navigation handling for the chat screen // Set up back navigation handling for the chat screen
val backCallback = object : OnBackPressedCallback(true) { val backCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
@ -354,7 +277,7 @@ class MainActivity : OrientationAwareActivity() {
when (state) { when (state) {
OnboardingState.COMPLETE -> { OnboardingState.COMPLETE -> {
// App is fully initialized, mesh service is running // 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 -> { OnboardingState.ERROR -> {
android.util.Log.e("MainActivity", "Onboarding error state reached") android.util.Log.e("MainActivity", "Onboarding error state reached")
@ -364,6 +287,8 @@ class MainActivity : OrientationAwareActivity() {
} }
private fun checkOnboardingStatus() { private fun checkOnboardingStatus() {
Log.d("MainActivity", "Checking onboarding status")
lifecycleScope.launch { lifecycleScope.launch {
// Small delay to show the checking state // Small delay to show the checking state
delay(500) delay(500)
@ -377,15 +302,12 @@ class MainActivity : OrientationAwareActivity() {
* Check Bluetooth status and proceed with onboarding flow * Check Bluetooth status and proceed with onboarding flow
*/ */
private fun checkBluetoothAndProceed() { private fun checkBluetoothAndProceed() {
// Check if user has skipped Bluetooth check for this session // Log.d("MainActivity", "Checking Bluetooth status")
if (mainViewModel.isBluetoothCheckSkipped.value) {
checkLocationAndProceed()
return
}
// For first-time users, skip Bluetooth check and go straight to permissions // For first-time users, skip Bluetooth check and go straight to permissions
// We'll check Bluetooth after permissions are granted // We'll check Bluetooth after permissions are granted
if (permissionManager.isFirstTimeLaunch()) { if (permissionManager.isFirstTimeLaunch()) {
Log.d("MainActivity", "First-time launch, skipping Bluetooth check - will check after permissions")
proceedWithPermissionCheck() proceedWithPermissionCheck()
return return
} }
@ -394,12 +316,6 @@ class MainActivity : OrientationAwareActivity() {
bluetoothStatusManager.logBluetoothStatus() bluetoothStatusManager.logBluetoothStatus()
mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus()) mainViewModel.updateBluetoothStatus(bluetoothStatusManager.checkBluetoothStatus())
val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
if (!bleRequired) {
// Skip BLE checks entirely when BLE is disabled in debug settings
checkLocationAndProceed()
return
}
when (mainViewModel.bluetoothStatus.value) { when (mainViewModel.bluetoothStatus.value) {
BluetoothStatus.ENABLED -> { BluetoothStatus.ENABLED -> {
// Bluetooth is enabled, check location services next // Bluetooth is enabled, check location services next
@ -407,6 +323,7 @@ class MainActivity : OrientationAwareActivity() {
} }
BluetoothStatus.DISABLED -> { BluetoothStatus.DISABLED -> {
// Show Bluetooth enable screen (should have permissions as existing user) // Show Bluetooth enable screen (should have permissions as existing user)
Log.d("MainActivity", "Bluetooth disabled, showing enable screen")
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
mainViewModel.updateBluetoothLoading(false) mainViewModel.updateBluetoothLoading(false)
} }
@ -423,24 +340,20 @@ class MainActivity : OrientationAwareActivity() {
* Proceed with permission checking * Proceed with permission checking
*/ */
private fun proceedWithPermissionCheck() { private fun proceedWithPermissionCheck() {
Log.d("MainActivity", "Proceeding with permission check")
lifecycleScope.launch { lifecycleScope.launch {
delay(200) // Small delay for smooth transition delay(200) // Small delay for smooth transition
if (permissionManager.isFirstTimeLaunch()) { if (permissionManager.isFirstTimeLaunch()) {
Log.d("MainActivity", "First time launch, showing permission explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
} else if (permissionManager.getUnrequestedOptionalPermissions().isNotEmpty()) { } else if (permissionManager.areAllPermissionsGranted()) {
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) Log.d("MainActivity", "Existing user with permissions, initializing app")
} else if (permissionManager.areRequiredPermissionsGranted()) { mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
if (permissionManager.needsBackgroundLocationPermission() && initializeApp()
!permissionManager.isBackgroundLocationGranted() &&
!com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity)
) {
mainViewModel.updateOnboardingState(OnboardingState.BACKGROUND_LOCATION_EXPLANATION)
} else {
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
initializeApp()
}
} else { } else {
Log.d("MainActivity", "Existing user missing permissions, showing explanation")
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION) mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
} }
} }
@ -450,6 +363,7 @@ class MainActivity : OrientationAwareActivity() {
* Handle Bluetooth enabled callback * Handle Bluetooth enabled callback
*/ */
private fun handleBluetoothEnabled() { private fun handleBluetoothEnabled() {
Log.d("MainActivity", "Bluetooth enabled by user")
mainViewModel.updateBluetoothLoading(false) mainViewModel.updateBluetoothLoading(false)
mainViewModel.updateBluetoothStatus(BluetoothStatus.ENABLED) mainViewModel.updateBluetoothStatus(BluetoothStatus.ENABLED)
checkLocationAndProceed() checkLocationAndProceed()
@ -459,9 +373,12 @@ class MainActivity : OrientationAwareActivity() {
* Check Location services status and proceed with onboarding flow * Check Location services status and proceed with onboarding flow
*/ */
private fun checkLocationAndProceed() { private fun checkLocationAndProceed() {
Log.d("MainActivity", "Checking location services status")
// For first-time users, skip location check and go straight to permissions // For first-time users, skip location check and go straight to permissions
// We'll check location after permissions are granted // We'll check location after permissions are granted
if (permissionManager.isFirstTimeLaunch()) { if (permissionManager.isFirstTimeLaunch()) {
Log.d("MainActivity", "First-time launch, skipping location check - will check after permissions")
proceedWithPermissionCheck() proceedWithPermissionCheck()
return return
} }
@ -477,6 +394,7 @@ class MainActivity : OrientationAwareActivity() {
} }
LocationStatus.DISABLED -> { LocationStatus.DISABLED -> {
// Show location enable screen (should have permissions as existing user) // 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.updateOnboardingState(OnboardingState.LOCATION_CHECK)
mainViewModel.updateLocationLoading(false) mainViewModel.updateLocationLoading(false)
} }
@ -493,10 +411,9 @@ class MainActivity : OrientationAwareActivity() {
* Handle Location enabled callback * Handle Location enabled callback
*/ */
private fun handleLocationEnabled() { private fun handleLocationEnabled() {
Log.d("MainActivity", "Location services enabled by user")
mainViewModel.updateLocationLoading(false) mainViewModel.updateLocationLoading(false)
mainViewModel.updateLocationStatus(LocationStatus.ENABLED) mainViewModel.updateLocationStatus(LocationStatus.ENABLED)
// Ensure Wi-Fi Aware starts now that location is enabled
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
checkBatteryOptimizationAndProceed() checkBatteryOptimizationAndProceed()
} }
@ -538,10 +455,12 @@ class MainActivity : OrientationAwareActivity() {
message.contains("Permission") && permissionManager.isFirstTimeLaunch() -> { message.contains("Permission") && permissionManager.isFirstTimeLaunch() -> {
// During first-time onboarding, if Bluetooth enable fails due to permissions, // During first-time onboarding, if Bluetooth enable fails due to permissions,
// proceed to permission explanation screen where user will grant permissions first // proceed to permission explanation screen where user will grant permissions first
Log.d("MainActivity", "Bluetooth enable requires permissions, proceeding to permission explanation")
proceedWithPermissionCheck() proceedWithPermissionCheck()
} }
message.contains("Permission") -> { message.contains("Permission") -> {
// For existing users, redirect to permission explanation to grant missing permissions // 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) mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
} }
else -> { else -> {
@ -552,6 +471,8 @@ class MainActivity : OrientationAwareActivity() {
} }
private fun handleOnboardingComplete() { 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 // After permissions are granted, re-check Bluetooth, Location, and Battery Optimization status
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
val currentLocationStatus = locationStatusManager.checkLocationStatus() val currentLocationStatus = locationStatusManager.checkLocationStatus()
@ -561,28 +482,31 @@ class MainActivity : OrientationAwareActivity() {
else -> BatteryOptimizationStatus.ENABLED else -> BatteryOptimizationStatus.ENABLED
} }
val bleRequired2 = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true }
when { when {
bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> { currentBluetoothStatus != BluetoothStatus.ENABLED -> {
// Bluetooth still disabled, but now we have permissions to enable it // 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.updateBluetoothStatus(currentBluetoothStatus)
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
mainViewModel.updateBluetoothLoading(false) mainViewModel.updateBluetoothLoading(false)
} }
currentLocationStatus != LocationStatus.ENABLED -> { currentLocationStatus != LocationStatus.ENABLED -> {
// Location services still disabled, but now we have permissions to enable it // 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.updateLocationStatus(currentLocationStatus)
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
mainViewModel.updateLocationLoading(false) mainViewModel.updateLocationLoading(false)
} }
currentBatteryOptimizationStatus == BatteryOptimizationStatus.ENABLED -> { currentBatteryOptimizationStatus == BatteryOptimizationStatus.ENABLED -> {
// Battery optimization still enabled, show battery optimization screen // 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.updateBatteryOptimizationStatus(currentBatteryOptimizationStatus)
mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK) mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK)
mainViewModel.updateBatteryOptimizationLoading(false) mainViewModel.updateBatteryOptimizationLoading(false)
} }
else -> { else -> {
// Both are enabled, proceed to app initialization // Both are enabled, proceed to app initialization
Log.d("MainActivity", "Both Bluetooth and Location services are enabled, proceeding to initialization")
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING) mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
initializeApp() initializeApp()
} }
@ -594,36 +518,17 @@ class MainActivity : OrientationAwareActivity() {
mainViewModel.updateErrorMessage(message) mainViewModel.updateErrorMessage(message)
mainViewModel.updateOnboardingState(OnboardingState.ERROR) mainViewModel.updateOnboardingState(OnboardingState.ERROR)
} }
private fun startMeshForegroundServiceBestEffort() {
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
pendingMeshForegroundServiceStart = true
Log.i("MainActivity", "Deferring foreground mesh service start until activity is started")
return
}
try {
com.bitchat.android.service.MeshForegroundService.start(applicationContext)
pendingMeshForegroundServiceStart = false
} catch (e: Exception) {
pendingMeshForegroundServiceStart = true
Log.w("MainActivity", "Unable to start foreground mesh service; will retry when activity is started", e)
}
}
/** /**
* Check Battery Optimization status and proceed with onboarding flow * Check Battery Optimization status and proceed with onboarding flow
*/ */
private fun checkBatteryOptimizationAndProceed() { 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 // For first-time users, skip battery optimization check and go straight to permissions
// We'll check battery optimization after permissions are granted // We'll check battery optimization after permissions are granted
if (permissionManager.isFirstTimeLaunch()) { if (permissionManager.isFirstTimeLaunch()) {
proceedWithPermissionCheck() android.util.Log.d("MainActivity", "First-time launch, skipping battery optimization check - will check after permissions")
return
}
// Check if user has previously skipped battery optimization
if (BatteryOptimizationPreferenceManager.isSkipped(this)) {
proceedWithPermissionCheck() proceedWithPermissionCheck()
return return
} }
@ -644,6 +549,7 @@ class MainActivity : OrientationAwareActivity() {
} }
BatteryOptimizationStatus.ENABLED -> { BatteryOptimizationStatus.ENABLED -> {
// Show battery optimization disable screen // Show battery optimization disable screen
android.util.Log.d("MainActivity", "Battery optimization enabled, showing disable screen")
mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK) mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK)
mainViewModel.updateBatteryOptimizationLoading(false) mainViewModel.updateBatteryOptimizationLoading(false)
} }
@ -654,6 +560,7 @@ class MainActivity : OrientationAwareActivity() {
* Handle Battery Optimization disabled callback * Handle Battery Optimization disabled callback
*/ */
private fun handleBatteryOptimizationDisabled() { private fun handleBatteryOptimizationDisabled() {
android.util.Log.d("MainActivity", "Battery optimization disabled by user")
mainViewModel.updateBatteryOptimizationLoading(false) mainViewModel.updateBatteryOptimizationLoading(false)
mainViewModel.updateBatteryOptimizationStatus(BatteryOptimizationStatus.DISABLED) mainViewModel.updateBatteryOptimizationStatus(BatteryOptimizationStatus.DISABLED)
proceedWithPermissionCheck() proceedWithPermissionCheck()
@ -677,17 +584,19 @@ class MainActivity : OrientationAwareActivity() {
} }
private fun initializeApp() { private fun initializeApp() {
Log.d("MainActivity", "Starting app initialization")
lifecycleScope.launch { lifecycleScope.launch {
try { try {
// Initialize the app with a proper delay to ensure Bluetooth stack is ready // 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 // This solves the issue where app needs restart to work on first install
delay(1000) // Give the system time to process permission grants 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 // Initialize PoW preferences early in the initialization process
PoWPreferenceManager.init(this@MainActivity) 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)
// Ensure all permissions are still granted (user might have revoked in settings) // Ensure all permissions are still granted (user might have revoked in settings)
if (!permissionManager.areAllPermissionsGranted()) { if (!permissionManager.areAllPermissionsGranted()) {
@ -697,18 +606,18 @@ class MainActivity : OrientationAwareActivity() {
return@launch return@launch
} }
// Set up unified mesh delegate and start enabled transports // Set up mesh service delegate and start services
unifiedMeshService.delegate = chatViewModel meshService.delegate = chatViewModel
unifiedMeshService.startServices() meshService.startServices()
startMeshForegroundServiceBestEffort()
Log.d("MainActivity", "Mesh service started successfully")
// Handle any notification intent // Handle any notification intent
handleNotificationIntent(intent) handleNotificationIntent(intent)
handleVerificationIntent(intent)
// Small delay to ensure mesh service is fully initialized // Small delay to ensure mesh service is fully initialized
delay(500) delay(500)
Log.i("MainActivity", "App initialization complete") Log.d("MainActivity", "App initialization complete")
mainViewModel.updateOnboardingState(OnboardingState.COMPLETE) mainViewModel.updateOnboardingState(OnboardingState.COMPLETE)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("MainActivity", "Failed to initialize app", e) Log.e("MainActivity", "Failed to initialize app", e)
@ -719,45 +628,23 @@ class MainActivity : OrientationAwareActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent)
// Check if this is a quit request from the notification
if (intent.getBooleanExtra("ACTION_QUIT_APP", false)) {
android.util.Log.d("MainActivity", "Quit request received, finishing activity")
finish()
return
}
com.bitchat.android.service.AppShutdownCoordinator.cancelPendingShutdown()
// Handle notification intents when app is already running // Handle notification intents when app is already running
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
handleNotificationIntent(intent) handleNotificationIntent(intent)
handleVerificationIntent(intent)
}
}
override fun onStart() {
super.onStart()
if (pendingMeshForegroundServiceStart) {
startMeshForegroundServiceBestEffort()
} }
} }
override fun onResume() { override fun onResume() {
super.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 // Check Bluetooth and Location status on resume and handle accordingly
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Reattach mesh delegate to new ChatViewModel instance after Activity recreation // Set app foreground state
try { unifiedMeshService.delegate = chatViewModel } catch (_: Exception) { } meshService.connectionManager.setAppBackgroundState(false)
chatViewModel.setAppBackgroundState(false)
// Check if Bluetooth was disabled while app was backgrounded // Check if Bluetooth was disabled while app was backgrounded
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus() val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
val bleRequired = try { com.bitchat.android.ui.debug.DebugPreferenceManager.getBleEnabled(true) } catch (_: Exception) { true } if (currentBluetoothStatus != BluetoothStatus.ENABLED) {
if (bleRequired && currentBluetoothStatus != BluetoothStatus.ENABLED && !mainViewModel.isBluetoothCheckSkipped.value) {
Log.w("MainActivity", "Bluetooth disabled while app was backgrounded") Log.w("MainActivity", "Bluetooth disabled while app was backgrounded")
mainViewModel.updateBluetoothStatus(currentBluetoothStatus) mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK) mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
@ -772,19 +659,17 @@ class MainActivity : OrientationAwareActivity() {
mainViewModel.updateLocationStatus(currentLocationStatus) mainViewModel.updateLocationStatus(currentLocationStatus)
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK) mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
mainViewModel.updateLocationLoading(false) mainViewModel.updateLocationLoading(false)
} else {
// If location is enabled, ensure Wi-Fi Aware starts if it was blocked by location earlier
com.bitchat.android.wifiaware.WifiAwareController.startIfPossible()
} }
} }
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
// Only set background state if app is fully initialized // Only set background state if app is fully initialized
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
// Detach UI delegate so the foreground service can own DM notifications while UI is closed // Set app background state
try { unifiedMeshService.delegate = null } catch (_: Exception) { } meshService.connectionManager.setAppBackgroundState(true)
chatViewModel.setAppBackgroundState(true)
} }
} }
@ -810,9 +695,8 @@ class MainActivity : OrientationAwareActivity() {
if (peerID != null) { if (peerID != null) {
Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification") Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification")
// Open the private chat sheet with this peer // Open the private chat with this peer
chatViewModel.showMeshPeerList() chatViewModel.startPrivateChat(peerID)
chatViewModel.showPrivateChatSheet(peerID)
// Clear notifications for this sender since user is now viewing the chat // Clear notifications for this sender since user is now viewing the chat
chatViewModel.clearNotificationsForSender(peerID) chatViewModel.clearNotificationsForSender(peerID)
@ -823,7 +707,7 @@ class MainActivity : OrientationAwareActivity() {
val geohash = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_GEOHASH) val geohash = intent.getStringExtra(com.bitchat.android.ui.NotificationManager.EXTRA_GEOHASH)
if (geohash != null) { 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 // Switch to the geohash channel - create appropriate geohash channel level
val level = when (geohash.length) { val level = when (geohash.length) {
@ -848,30 +732,26 @@ class MainActivity : OrientationAwareActivity() {
} }
} }
private fun handleVerificationIntent(intent: Intent) {
val uri = intent.data ?: return
if (uri.scheme != "bitchat" || uri.host != "verify") return
chatViewModel.showVerificationSheet()
val qr = VerificationService.verifyScannedQR(uri.toString())
if (qr != null) {
chatViewModel.beginQRVerification(qr)
}
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
try { unregisterReceiver(forceFinishReceiver) } catch (_: Exception) { }
// Cleanup location status manager // Cleanup location status manager
try { try {
locationStatusManager.cleanup() locationStatusManager.cleanup()
Log.d("MainActivity", "Location status manager cleaned up successfully")
} catch (e: Exception) { } catch (e: Exception) {
Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}") Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
} }
// Do not stop mesh here; ForegroundService owns lifecycle for background reliability // Stop mesh services if app was fully initialized
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
try {
meshService.stopServices()
Log.d("MainActivity", "Mesh services stopped successfully")
} catch (e: Exception) {
Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
}
}
} }
} }

View File

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

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

View File

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

View File

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

View File

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

@ -1,15 +1,8 @@
package com.bitchat.android.crypto package com.bitchat.android.crypto
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.util.Base64
import android.util.Log 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.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.AsymmetricCipherKeyPair
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters
@ -18,7 +11,6 @@ import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer import org.bouncycastle.crypto.signers.Ed25519Signer
import java.security.SecureRandom import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import androidx.core.content.edit
/** /**
* Encryption service that now uses NoiseEncryptionService internally * Encryption service that now uses NoiseEncryptionService internally
@ -27,55 +19,29 @@ import androidx.core.content.edit
* This is the main interface for all encryption/decryption operations in bitchat. * This is the main interface for all encryption/decryption operations in bitchat.
* It now uses the Noise protocol for secure transport encryption with proper session management. * It now uses the Noise protocol for secure transport encryption with proper session management.
*/ */
open class EncryptionService(private val context: Context) { class EncryptionService(private val context: Context) {
companion object { companion object {
private const val TAG = "EncryptionService" private const val TAG = "EncryptionService"
private const val ED25519_PRIVATE_KEY_PREF = "ed25519_signing_private_key" private const val ED25519_PRIVATE_KEY_PREF = "ed25519_signing_private_key"
private const val OLD_PREFS_NAME = "bitchat_crypto"
private const val SECURE_PREFS_NAME = "bitchat_crypto_secure"
} }
// Core Noise encryption service // Core Noise encryption service
private val noiseService: NoiseEncryptionService by lazy { NoiseEncryptionService(context) } private val noiseService: NoiseEncryptionService = NoiseEncryptionService(context)
// Session tracking for established connections // Session tracking for established connections
private val establishedSessions = ConcurrentHashMap<String, String>() // peerID -> fingerprint private val establishedSessions = ConcurrentHashMap<String, String>() // peerID -> fingerprint
// Ed25519 signing keys (separate from Noise static keys) // Ed25519 signing keys (separate from Noise static keys)
private lateinit var ed25519PrivateKey: Ed25519PrivateKeyParameters private val ed25519PrivateKey: Ed25519PrivateKeyParameters
private lateinit var ed25519PublicKey: Ed25519PublicKeyParameters private val ed25519PublicKey: Ed25519PublicKeyParameters
// Callbacks for UI state updates // Callbacks for UI state updates
var onSessionEstablished: ((String) -> Unit)? = null // peerID var onSessionEstablished: ((String) -> Unit)? = null // peerID
var onSessionLost: ((String) -> Unit)? = null // peerID var onSessionLost: ((String) -> Unit)? = null // peerID
var onHandshakeRequired: ((String) -> Unit)? = null // peerID var onHandshakeRequired: ((String) -> Unit)? = null // peerID
private lateinit var prefs: SharedPreferences
init { init {
initialize()
}
private fun setUpEncryptedPrefs() {
val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
// Create encrypted shared preferences
prefs = EncryptedSharedPreferences.create(
context,
SECURE_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
/**
* Initialization logic moved to method to allow overriding in tests
*/
protected open fun initialize() {
setUpEncryptedPrefs()
// Initialize or load Ed25519 signing keys // Initialize or load Ed25519 signing keys
val keyPair = loadOrCreateEd25519KeyPair() val keyPair = loadOrCreateEd25519KeyPair()
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
@ -169,14 +135,9 @@ open class EncryptionService(private val context: Context) {
// Clear Ed25519 signing key from preferences // Clear Ed25519 signing key from preferences
try { try {
prefs.edit { remove(ED25519_PRIVATE_KEY_PREF) } val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE)
prefs.edit().remove(ED25519_PRIVATE_KEY_PREF).apply()
Log.d(TAG, "🗑️ Cleared Ed25519 signing keys from preferences") Log.d(TAG, "🗑️ Cleared Ed25519 signing keys from preferences")
// Generate new keys immediately
val keyPair = loadOrCreateEd25519KeyPair()
ed25519PrivateKey = keyPair.private as Ed25519PrivateKeyParameters
ed25519PublicKey = keyPair.public as Ed25519PublicKeyParameters
Log.d(TAG, "✅ Rotated Ed25519 signing keys in memory")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "❌ Failed to clear Ed25519 keys: ${e.message}") Log.e(TAG, "❌ Failed to clear Ed25519 keys: ${e.message}")
} }
@ -193,13 +154,6 @@ open class EncryptionService(private val context: Context) {
} }
return encrypted 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 * Decrypt data from a specific peer using Noise transport encryption
@ -212,12 +166,6 @@ open class EncryptionService(private val context: Context) {
} }
return decrypted 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 * Sign data using our static identity key
@ -270,25 +218,6 @@ open class EncryptionService(private val context: Context) {
fun getPeerFingerprint(peerID: String): String? { fun getPeerFingerprint(peerID: String): String? {
return noiseService.getPeerFingerprint(peerID) 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) * Get current peer ID for a fingerprint (for peer ID rotation)
@ -300,9 +229,9 @@ open class EncryptionService(private val context: Context) {
/** /**
* Initiate a Noise handshake with a peer * 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") Log.d(TAG, "🤝 Initiating Noise handshake with $peerID")
return noiseService.initiateHandshake(peerID, replaceEstablished) return noiseService.initiateHandshake(peerID)
} }
/** /**
@ -312,24 +241,11 @@ open class EncryptionService(private val context: Context) {
Log.d(TAG, "🤝 Processing handshake message from $peerID") Log.d(TAG, "🤝 Processing handshake message from $peerID")
return noiseService.processHandshakeMessage(data, 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) * Remove a peer session (called when peer disconnects)
*/ */
open fun removePeer(peerID: String) { fun removePeer(peerID: String) {
establishedSessions.remove(peerID) establishedSessions.remove(peerID)
noiseService.removePeer(peerID) noiseService.removePeer(peerID)
onSessionLost?.invoke(peerID) onSessionLost?.invoke(peerID)
@ -440,7 +356,7 @@ open class EncryptionService(private val context: Context) {
/** /**
* Verify Ed25519 signature against data using a public key * Verify Ed25519 signature against data using a public key
*/ */
open fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean { fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
return try { return try {
val publicKey = Ed25519PublicKeyParameters(publicKeyBytes, 0) val publicKey = Ed25519PublicKeyParameters(publicKeyBytes, 0)
val verifier = Ed25519Signer() val verifier = Ed25519Signer()
@ -461,14 +377,13 @@ open class EncryptionService(private val context: Context) {
* Load existing Ed25519 key pair from preferences or create a new one * Load existing Ed25519 key pair from preferences or create a new one
*/ */
private fun loadOrCreateEd25519KeyPair(): AsymmetricCipherKeyPair { private fun loadOrCreateEd25519KeyPair(): AsymmetricCipherKeyPair {
// Migrate legacy plaintext Ed25519 key to encrypted storage if present
migrateOldEd25519KeyIfNeeded()
try { try {
val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE)
val storedKey = prefs.getString(ED25519_PRIVATE_KEY_PREF, null) val storedKey = prefs.getString(ED25519_PRIVATE_KEY_PREF, null)
if (storedKey != null) { if (storedKey != null) {
// Load existing key // Load existing key
val privateKeyBytes = Base64.decode(storedKey, Base64.DEFAULT) val privateKeyBytes = android.util.Base64.decode(storedKey, android.util.Base64.DEFAULT)
val privateKey = Ed25519PrivateKeyParameters(privateKeyBytes, 0) val privateKey = Ed25519PrivateKeyParameters(privateKeyBytes, 0)
val publicKey = privateKey.generatePublicKey() val publicKey = privateKey.generatePublicKey()
Log.d(TAG, "✅ Loaded existing Ed25519 signing key pair") Log.d(TAG, "✅ Loaded existing Ed25519 signing key pair")
@ -479,21 +394,18 @@ open class EncryptionService(private val context: Context) {
} }
// Create new key pair // Create new key pair
return generateAndSaveEd25519KeyPair()
}
fun generateAndSaveEd25519KeyPair(): AsymmetricCipherKeyPair {
val keyGen = Ed25519KeyPairGenerator() val keyGen = Ed25519KeyPairGenerator()
keyGen.init(Ed25519KeyGenerationParameters(SecureRandom())) keyGen.init(Ed25519KeyGenerationParameters(SecureRandom()))
val keyPair = keyGen.generateKeyPair() val keyPair = keyGen.generateKeyPair()
// Store private key in preferences // Store private key in preferences
try { try {
val privateKey = keyPair.private as Ed25519PrivateKeyParameters val privateKey = keyPair.private as Ed25519PrivateKeyParameters
val privateKeyBytes = privateKey.encoded val privateKeyBytes = privateKey.encoded
val encodedKey = Base64.encodeToString(privateKeyBytes, Base64.DEFAULT) val encodedKey = android.util.Base64.encodeToString(privateKeyBytes, android.util.Base64.DEFAULT)
prefs.edit { putString(ED25519_PRIVATE_KEY_PREF, encodedKey) } val prefs = context.getSharedPreferences("bitchat_crypto", Context.MODE_PRIVATE)
prefs.edit().putString(ED25519_PRIVATE_KEY_PREF, encodedKey).apply()
Log.d(TAG, "✅ Created and stored new Ed25519 signing key pair") Log.d(TAG, "✅ Created and stored new Ed25519 signing key pair")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "❌ Failed to store Ed25519 private key: ${e.message}") Log.e(TAG, "❌ Failed to store Ed25519 private key: ${e.message}")
@ -501,25 +413,4 @@ open class EncryptionService(private val context: Context) {
return keyPair return keyPair
} }
private fun migrateOldEd25519KeyIfNeeded() {
try {
// old existing plain text preference
val oldPrefs = context.getSharedPreferences(OLD_PREFS_NAME, Context.MODE_PRIVATE)
val oldKey = oldPrefs.getString(ED25519_PRIVATE_KEY_PREF, null)
if (oldKey != null && !prefs.contains(ED25519_PRIVATE_KEY_PREF)) {
prefs.edit {
putString(ED25519_PRIVATE_KEY_PREF, oldKey)
}
oldPrefs.edit {
remove(ED25519_PRIVATE_KEY_PREF)
}
Log.d(TAG, "🔁 Migrated Ed25519 key to EncryptedSharedPreferences")
}
} catch (e: Exception) {
Log.w(TAG, "⚠️ Failed to migrate Ed25519 key; generating new identity: ${e.message}")
}
}
} }

View File

@ -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.content.Context
import android.util.Log import android.util.Log
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.identity.SecureIdentityStateManager import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.services.ContactIdentityResolver
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import java.util.* import java.util.*
/** /**
* Bridging Noise and Nostr favorites * 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( data class FavoriteRelationship(
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes) 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 { interface FavoritesChangeListener {
fun onFavoriteChanged(noiseKeyHex: String) fun onFavoriteChanged(noiseKeyHex: String)
fun onAllCleared() fun onAllCleared()
@ -103,6 +84,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
private val stateManager = SecureIdentityStateManager(context) private val stateManager = SecureIdentityStateManager(context)
private val gson = Gson() private val gson = Gson()
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseHex -> relationship 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 peerIdIndex = mutableMapOf<String, String>() // peerID (lowercase 16-hex) -> npub
private val listeners = mutableListOf<FavoritesChangeListener>() private val listeners = mutableListOf<FavoritesChangeListener>()
@ -113,55 +95,35 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Get favorite status for Noise public key */ /** Get favorite status for Noise public key */
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? { fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
return favorites[keyHex] 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? { fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
val pid = peerID.trim().lowercase() val pid = peerID.lowercase()
for ((_, relationship) in favorites) {
if (ContactIdentityResolver.isNoiseKeyHex(pid)) { val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
return favorites[pid] 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 return null
} }
/** Update Nostr public key for a peer (indexed by Noise key) */ /** Update Nostr public key for a peer (indexed by Noise key) */
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) { fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey)
?.let { ContactIdentityResolver.npubFromHex(it) }
?: nostrPubkey
val existing = favorites[keyHex] val existing = favorites[keyHex]
if (existing != null) { if (existing != null) {
val updated = existing.copy( val updated = existing.copy(
peerNostrPublicKey = normalizedNpub, peerNostrPublicKey = nostrPubkey,
lastUpdated = Date() lastUpdated = Date()
) )
favorites[keyHex] = updated favorites[keyHex] = updated
} else { } else {
val relationship = FavoriteRelationship( val relationship = FavoriteRelationship(
peerNoisePublicKey = noisePublicKey, peerNoisePublicKey = noisePublicKey,
peerNostrPublicKey = normalizedNpub, peerNostrPublicKey = nostrPubkey,
peerNickname = "Unknown", peerNickname = "Unknown",
isFavorite = false, isFavorite = false,
theyFavoritedUs = false, theyFavoritedUs = false,
@ -176,50 +138,45 @@ class FavoritesPersistenceService private constructor(private val context: Conte
Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...") Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...")
} }
/** NEW: Update Nostr pubkey for specific mesh peerID (16-hex). */
/** Update Nostr pubkey for a specific mesh peerID. */
fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) { fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) {
val pid = peerID.trim().lowercase() val pid = peerID.lowercase()
val normalizedNpub = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) if (pid.length == 16 && pid.matches(Regex("^[0-9a-f]+$"))) {
?.let { ContactIdentityResolver.npubFromHex(it) } peerIdIndex[pid] = nostrPubkey
?: nostrPubkey
if (ContactIdentityResolver.isMeshPeerId(pid)) {
peerIdIndex[pid] = normalizedNpub
savePeerIdIndex() savePeerIdIndex()
notifyChanged(pid)
Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}") Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}")
} else { } else {
Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID") Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID")
} }
} }
/** NEW: Resolve Nostr pubkey via current peerID mapping (fast path). */
/** Resolve Nostr pubkey via current peerID mapping or stored Noise identity. */
fun findNostrPubkeyForPeerID(peerID: String): String? { fun findNostrPubkeyForPeerID(peerID: String): String? {
val pid = peerID.trim().lowercase() return peerIdIndex[peerID.lowercase()]
return peerIdIndex[pid] ?: getFavoriteStatus(pid)?.peerNostrPublicKey
} }
/** 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? { fun findPeerIDForNostrPubkey(nostrPubkey: String): String? {
val targetHex = ContactIdentityResolver.nostrPubkeyHex(nostrPubkey) ?: return null // 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 }
peerIdIndex.entries.firstOrNull { (_, stored) ->
ContactIdentityResolver.nostrPubkeyHex(stored) == targetHex // Attempt legacy mapping via favorites Noise key association
}?.let { return it.key } val targetHex = normalizeNostrKeyToHex(nostrPubkey)
if (targetHex != null) {
favorites.values.firstOrNull { relationship -> // Find relationship with matching nostr pubkey (normalized to hex) and then try to map to current peerID via noise key prefix
relationship.peerNostrPublicKey?.let { ContactIdentityResolver.nostrPubkeyHex(it) } == targetHex val rel = favorites.values.firstOrNull { it.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex }
}?.let { relationship -> if (rel != null) {
return ContactIdentityResolver.peerIdForNoiseKey(relationship.peerNoisePublicKey) 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 return null
} }
/** Update favorite status */ /** Update favorite status */
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) { fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val existing = favorites[keyHex] val existing = favorites[keyHex]
@ -251,20 +208,24 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Update peer favorited-us flag */ /** Update peer favorited-us flag */
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) { fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
val existing = favorites[keyHex] val existing = favorites[keyHex]
val updated = existing.withPeerFavoritedUs(noisePublicKey, theyFavoritedUs)
favorites[keyHex] = updated if (existing != null) {
saveFavorites() val updated = existing.copy(
notifyChanged(keyHex) 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 getMutualFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isMutual }
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite } fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
fun getAllRelationships(): List<FavoriteRelationship> = favorites.values.toList()
fun clearAllFavorites() { fun clearAllFavorites() {
favorites.clear() favorites.clear()
@ -277,15 +238,15 @@ class FavoritesPersistenceService private constructor(private val context: Conte
/** Find Noise key by Nostr pubkey */ /** Find Noise key by Nostr pubkey */
fun findNoiseKey(forNostrPubkey: String): ByteArray? { fun findNoiseKey(forNostrPubkey: String): ByteArray? {
val targetHex = ContactIdentityResolver.nostrPubkeyHex(forNostrPubkey) ?: return null val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null
return favorites.values.firstOrNull { rel -> return favorites.values.firstOrNull { rel ->
rel.peerNostrPublicKey?.let { stored -> ContactIdentityResolver.nostrPubkeyHex(stored) } == targetHex rel.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex
}?.peerNoisePublicKey }?.peerNoisePublicKey
} }
/** Find Nostr pubkey by Noise key */ /** Find Nostr pubkey by Noise key */
fun findNostrPubkey(forNoiseKey: ByteArray): String? { fun findNostrPubkey(forNoiseKey: ByteArray): String? {
val keyHex = ContactIdentityResolver.noiseKeyHex(forNoiseKey) val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
return favorites[keyHex]?.peerNostrPublicKey return favorites[keyHex]?.peerNostrPublicKey
} }
@ -329,12 +290,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
val type = object : TypeToken<Map<String, String>>() {}.type val type = object : TypeToken<Map<String, String>>() {}.type
val data: Map<String, String> = gson.fromJson(json, type) val data: Map<String, String> = gson.fromJson(json, type)
peerIdIndex.clear() peerIdIndex.clear()
data.forEach { (peerID, npub) -> peerIdIndex.putAll(data)
val normalizedPeerID = peerID.lowercase()
if (ContactIdentityResolver.isMeshPeerId(normalizedPeerID)) {
peerIdIndex[normalizedPeerID] = npub
}
}
Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings") Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings")
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -360,7 +316,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte
synchronized(listeners) { listeners.remove(listener) } synchronized(listeners) { listeners.remove(listener) }
} }
private fun notifyChanged(noiseKeyHex: String) { private fun notifyChanged(noiseKeyHex: String) {
runCatching { AppStateStore.canonicalizePrivateChats() }
val snapshot = synchronized(listeners) { listeners.toList() } val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { runCatching { it.onFavoriteChanged(noiseKeyHex) } } snapshot.forEach { runCatching { it.onFavoriteChanged(noiseKeyHex) } }
} }
@ -368,6 +323,14 @@ class FavoritesPersistenceService private constructor(private val context: Conte
val snapshot = synchronized(listeners) { listeners.toList() } val snapshot = synchronized(listeners) { listeners.toList() }
snapshot.forEach { runCatching { it.onAllCleared() } } 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 */ /** Serializable data for JSON storage */
@ -383,7 +346,7 @@ private data class FavoriteRelationshipData(
companion object { companion object {
fun fromFavoriteRelationship(relationship: FavoriteRelationship): FavoriteRelationshipData { fun fromFavoriteRelationship(relationship: FavoriteRelationship): FavoriteRelationshipData {
return FavoriteRelationshipData( return FavoriteRelationshipData(
peerNoisePublicKeyHex = ContactIdentityResolver.noiseKeyHex(relationship.peerNoisePublicKey), peerNoisePublicKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) },
peerNostrPublicKey = relationship.peerNostrPublicKey, peerNostrPublicKey = relationship.peerNostrPublicKey,
peerNickname = relationship.peerNickname, peerNickname = relationship.peerNickname,
isFavorite = relationship.isFavorite, isFavorite = relationship.isFavorite,
@ -395,7 +358,7 @@ private data class FavoriteRelationshipData(
} }
fun toFavoriteRelationship(): FavoriteRelationship { fun toFavoriteRelationship(): FavoriteRelationship {
val noiseKeyBytes = ContactIdentityResolver.bytesFromHex(peerNoisePublicKeyHex) ?: ByteArray(0) val noiseKeyBytes = peerNoisePublicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
return FavoriteRelationship( return FavoriteRelationship(
peerNoisePublicKey = noiseKeyBytes, peerNoisePublicKey = noiseKeyBytes,
peerNostrPublicKey = peerNostrPublicKey, peerNostrPublicKey = peerNostrPublicKey,

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,8 +11,6 @@ object Geohash {
private val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz".toCharArray() private val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz".toCharArray()
private val charToValue: Map<Char, Int> = base32Chars.withIndex().associate { it.value to it.index } private val charToValue: Map<Char, Int> = base32Chars.withIndex().associate { it.value to it.index }
data class Bounds(val latMin: Double, val latMax: Double, val lonMin: Double, val lonMax: Double)
/** /**
* Encodes the provided coordinates into a geohash string. * Encodes the provided coordinates into a geohash string.
* @param latitude Latitude in degrees (-90...90) * @param latitude Latitude in degrees (-90...90)
@ -71,24 +69,14 @@ object Geohash {
* @return Pair(latitude, longitude) * @return Pair(latitude, longitude)
*/ */
fun decodeToCenter(geohash: String): Pair<Double, Double> { fun decodeToCenter(geohash: String): Pair<Double, Double> {
val b = decodeToBounds(geohash) if (geohash.isEmpty()) return 0.0 to 0.0
val latCenter = (b.latMin + b.latMax) / 2
val lonCenter = (b.lonMin + b.lonMax) / 2
return latCenter to lonCenter
}
/**
* Decodes a geohash string to bounding box (lat/lon min/max).
*/
fun decodeToBounds(geohash: String): Bounds {
if (geohash.isEmpty()) return Bounds(0.0, 0.0, 0.0, 0.0)
var latInterval = -90.0 to 90.0 var latInterval = -90.0 to 90.0
var lonInterval = -180.0 to 180.0 var lonInterval = -180.0 to 180.0
var isEven = true var isEven = true
geohash.lowercase().forEach { ch -> geohash.lowercase().forEach { ch ->
val cd = charToValue[ch] ?: return Bounds(0.0, 0.0, 0.0, 0.0) val cd = charToValue[ch] ?: return 0.0 to 0.0
for (mask in intArrayOf(16, 8, 4, 2, 1)) { for (mask in intArrayOf(16, 8, 4, 2, 1)) {
if (isEven) { if (isEven) {
val mid = (lonInterval.first + lonInterval.second) / 2 val mid = (lonInterval.first + lonInterval.second) / 2
@ -108,43 +96,9 @@ object Geohash {
isEven = !isEven isEven = !isEven
} }
} }
return Bounds(
latMin = minOf(latInterval.first, latInterval.second),
latMax = maxOf(latInterval.first, latInterval.second),
lonMin = minOf(lonInterval.first, lonInterval.second),
lonMax = maxOf(lonInterval.first, lonInterval.second)
)
}
/** val latCenter = (latInterval.first + latInterval.second) / 2
* Returns the 8 neighboring geohash cells at the same precision as the input. val lonCenter = (lonInterval.first + lonInterval.second) / 2
* Neighbors include N, NE, E, SE, S, SW, W, NW, even when crossing parent cell boundaries. return latCenter to lonCenter
*/
fun neighborsSamePrecision(geohash: String): Set<String> {
if (geohash.isEmpty()) return emptySet()
val p = geohash.length
val b = decodeToBounds(geohash)
val dLat = b.latMax - b.latMin
val dLon = b.lonMax - b.lonMin
fun wrapLon(lon: Double): Double {
var x = lon
while (x > 180.0) x -= 360.0
while (x < -180.0) x += 360.0
return x
}
val neighbors = mutableSetOf<String>()
for (dy in -1..1) {
for (dx in -1..1) {
if (dx == 0 && dy == 0) continue // skip center
val centerLat = (b.latMin + b.latMax) / 2 + dy * dLat
val rawLonCenter = (b.lonMin + b.lonMax) / 2 + dx * dLon
val centerLon = wrapLon(rawLonCenter)
val enc = encode(centerLat.coerceIn(-90.0, 90.0), centerLon, p)
if (enc.isNotEmpty() && enc != geohash) neighbors.add(enc)
}
}
return neighbors
} }
} }

View File

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

View File

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

@ -5,7 +5,6 @@ package com.bitchat.android.geohash
* Direct port from iOS implementation for 100% compatibility * Direct port from iOS implementation for 100% compatibility
*/ */
enum class GeohashChannelLevel(val precision: Int, val displayName: String) { enum class GeohashChannelLevel(val precision: Int, val displayName: String) {
BUILDING(8, "Building"), // iOS: precision 8 for building-level (used for Location Notes)
BLOCK(7, "Block"), BLOCK(7, "Block"),
NEIGHBORHOOD(6, "Neighborhood"), NEIGHBORHOOD(6, "Neighborhood"),
CITY(5, "City"), CITY(5, "City"),
@ -36,18 +35,7 @@ data class GeohashChannel(
*/ */
sealed class ChannelID { sealed class ChannelID {
object Mesh : ChannelID() object Mesh : ChannelID()
data class Location(val channel: GeohashChannel) : ChannelID() { data class Location(val channel: GeohashChannel) : ChannelID()
companion object {
fun fromPersisted(levelName: String, geohash: String): Location? {
return try {
val level = GeohashChannelLevel.valueOf(levelName)
Location(GeohashChannel(level, geohash))
} catch (_: IllegalArgumentException) {
null
}
}
}
}
/** /**
* Human readable name for UI. * Human readable name for UI.

View File

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

View File

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

View File

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

View File

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

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